From caee82ee4c61ce6ae65beb26253cfc1ae59e1794 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 9 Sep 2023 06:57:16 +0200 Subject: [PATCH 001/113] move to typer and rich for cli interaction --- .gitignore | 1 + algobattle.ps1 | 61 ----- algobattle/cli.py | 590 ++++++++++++++++++++++---------------------- algobattle/match.py | 44 ++-- algobattle/team.py | 23 +- pyproject.toml | 7 +- tests/test_match.py | 19 +- 7 files changed, 349 insertions(+), 396 deletions(-) delete mode 100644 algobattle.ps1 diff --git a/.gitignore b/.gitignore index 05c3f492..2683c02c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist site docs/src/pairsum_solver/target docs/src/pairsum_solver/Cargo.lock +.results diff --git a/algobattle.ps1 b/algobattle.ps1 deleted file mode 100644 index 04acf7e4..00000000 --- a/algobattle.ps1 +++ /dev/null @@ -1,61 +0,0 @@ -[CmdletBinding()] -param ( - [Parameter(Position=0)] - [string]$problem, - - [Alias("h")] - [switch]$help, - - [Alias("s")] - [switch]$silent, - - [Alias("c")] - [string]$config, - - [Alias("r")] - [string]$result, - - [Alias("t")] - [string]$teams -) - -$mounts = "" -$docker_args = "" - -if ($problem) { - $mounts += "--mount type=bind,source=" + (Resolve-Path $problem) + ",target=/algobattle/problem,readonly " - $docker_args += "/algobattle/problem " -} -if ($help) { - $docker_args += "-h " -} -if ($silent) { - $docker_args += "-s " -} -if ($config) { - $mounts += "--mount type=bind,source=" + (Resolve-Path $config) + ",target=/algobattle/config,readonly " - $docker_args += "-c /algobattle/config " -} -if ($result) { - $mounts += "--mount type=bind,source=" + (Resolve-Path $result) + ",target=/algobattle/result " - $docker_args += "-r /algobattle/result " -} -if ($teams) { - $mounts += "--mount type=bind,source=" + (Resolve-Path $teams) + ",target=/algobattle/teams " -} - -$tempFolderPath = Join-Path $Env:Temp $(New-Guid) -New-Item -Type Directory -Path $tempFolderPath | Out-Null - -try { - Invoke-Expression ("docker run -it --rm " + - "--mount type=bind,source=" + $tempFolderPath + ",target=/algobattle/io " + - "--env ALGOBATTLE_IO_DIR=" + $tempFolderPath + " " + - "--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock " + - $mounts + - "algobattle " + - $docker_args - ) -} finally { - Remove-Item -LiteralPath $tempFolderPath -Force -Recurse -} diff --git a/algobattle/cli.py b/algobattle/cli.py index 27b2a86f..c678279e 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -2,344 +2,342 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ -from argparse import ArgumentParser -from types import TracebackType -import curses -from dataclasses import dataclass, field -import sys +from contextlib import ExitStack +from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Callable, ParamSpec, Self, TypeVar +from typing import Annotated, Any, Iterable, Literal, Optional, Self, cast +from typing_extensions import override from importlib.metadata import version as pkg_version -from prettytable import DOUBLE_BORDER, PrettyTable -from anyio import create_task_group, run, sleep -from anyio.abc import TaskGroup - -from algobattle.battle import Battle, Fight +from anyio import run as run_async_fn +from typer import Typer, Argument, Option +from rich.console import Group, RenderableType, Console +from rich.live import Live +from rich.table import Table, Column +from rich.progress import ( + Progress, + TextColumn, + SpinnerColumn, + BarColumn, + MofNCompleteColumn, + TimeElapsedColumn, + TaskID, + ProgressColumn, + Task, +) +from rich.panel import Panel +from rich.text import Text +from rich.columns import Columns + +from algobattle.battle import Battle from algobattle.match import BaseConfig, Match, Ui -from algobattle.problem import AnyProblem, Problem +from algobattle.problem import Problem from algobattle.team import Matchup -from algobattle.util import Role, RunningTimer, flat_intersperse +from algobattle.util import Role, RunningTimer -@dataclass -class CliOptions: - """Options used by the cli.""" - - problem: AnyProblem - silent: bool = False - result: Path | None = None - - -def parse_cli_args(args: list[str]) -> tuple[CliOptions, BaseConfig]: - """Parse a given CLI arg list into config objects.""" - parser = ArgumentParser() - parser.add_argument( - "path", - type=Path, - help="Path to either a config file or a directory containing one and/or the other necessary files.", - ) - parser.add_argument("--silent", "-s", action="store_true", help="Disable the cli Ui.") - parser.add_argument( - "--result", "-r", type=Path, help="If set, the match result object will be saved to the specified file." - ) - - parsed = parser.parse_args(args) - path: Path = parsed.path - if not path.exists(): - raise ValueError("Passed path does not exist.") - if path.is_dir(): - path /= "config.toml" +__all__ = ("app",) - config = BaseConfig.from_file(path) - problem = Problem.get(config.match.problem) - - exec_config = CliOptions( - problem=problem, - silent=parsed.silent, - result=parsed.result, - ) - return exec_config, config +app = Typer(pretty_exceptions_show_locals=False) +console = Console() -async def _run_with_ui( - match_config: BaseConfig, - problem: AnyProblem, +@app.command() +def run( + path: Annotated[Path, Argument(exists=True, help="Path to either a config file or a directory containing one.")], + ui: Annotated[bool, Option(help="Whether to show the CLI UI during match execution.")] = True, + result_path: Annotated[ + Optional[Path], # typer doesn't support union syntax + Option( + "--result", + "-r", + exists=True, + dir_okay=True, + file_okay=False, + writable=True, + help="If set, the match result object will be saved in the folder.", + ), + ] = None, ) -> Match: - async with CliUi() as ui: - return await Match.run(match_config, problem, ui) + if path.is_dir(): + path /= "config.toml" + config = BaseConfig.from_file(path) + problem = Problem.get(config.match.problem) -def main(): - """Entrypoint of `algobattle` CLI.""" try: - exec_config, config = parse_cli_args(sys.argv[1:]) - - if exec_config.silent: - result = run(Match.run, config, exec_config.problem) - else: - result = run(_run_with_ui, config, exec_config.problem) - print("\n".join(CliUi.display_match(result))) - + result = None + with ExitStack() as stack: + if ui: + ui_obj = stack.enter_context(CliUi()) + else: + ui_obj = None + result = run_async_fn(Match.run, config, problem, ui_obj) + assert result is not None + + console.print(CliUi.display_match(result)) if config.execution.points > 0: points = result.calculate_points(config.execution.points) for team, pts in points.items(): print(f"Team {team} gained {pts:.1f} points.") - if exec_config.result is not None: + if result_path is not None: t = datetime.now() filename = f"{t.year:04d}-{t.month:02d}-{t.day:02d}_{t.hour:02d}-{t.minute:02d}-{t.second:02d}.json" - with open(exec_config.result / filename, "w+") as f: + with open(result_path / filename, "w+") as f: f.write(result.model_dump_json(exclude_defaults=True)) + return result except KeyboardInterrupt: raise SystemExit("Received keyboard interrupt, terminating execution.") -P = ParamSpec("P") -R = TypeVar("R") - - -def check_for_terminal(function: Callable[P, R]) -> Callable[P, R | None]: - """Ensure that we are attached to a terminal.""" - - def wrapper(*args: P.args, **kwargs: P.kwargs): - if not sys.stdout.isatty(): - return None - else: - return function(*args, **kwargs) - - return wrapper - - -@dataclass -class _BuildInfo: - team: str - role: Role - timeout: float | None - start: datetime - - -@dataclass -class _FightUiData: - max_size: int - generator: RunningTimer | None = None - gen_runtime: float | None = None - solver: RunningTimer | None = None - sol_runtime: float | None = None - - @dataclass -class CliUi(Ui): - """A :class:`Ui` displaying the data to the cli. - - Uses curses to continually draw a basic text based ui to the terminal. - """ - - battle_data: dict[Matchup, Battle.UiData] = field(default_factory=dict, init=False) - fight_data: dict[Matchup, _FightUiData] = field(default_factory=dict, init=False) - task_group: TaskGroup | None = field(default=None, init=False) - build_status: _BuildInfo | str | None = field(default=None, init=False) - - async def __aenter__(self) -> Self: - self.stdscr = curses.initscr() - curses.cbreak() - curses.noecho() - self.stdscr.keypad(True) - - self.task_group = create_task_group() - await self.task_group.__aenter__() - self.task_group.start_soon(self.loop) - - return self - - async def __aexit__(self, _type: type[Exception], _value: Exception, _traceback: TracebackType) -> None: - """Restore the console.""" - if self.task_group is not None: - self.task_group.cancel_scope.cancel() - await self.task_group.__aexit__(_type, _value, _traceback) - - curses.nocbreak() - self.stdscr.keypad(False) - curses.echo() - curses.endwin() - - def start_build(self, team: str, role: Role, timeout: float | None) -> None: - """Informs the ui that a new program is being built.""" - self.build_status = _BuildInfo(team, role, timeout, datetime.now()) - - def finish_build(self) -> None: - """Informs the ui that the current build has been finished.""" - self.build_status = None - - @check_for_terminal - def battle_completed(self, matchup: Matchup) -> None: - """Notifies the Ui that a specific battle has been completed.""" - self.battle_data.pop(matchup, None) - self.fight_data.pop(matchup, None) - super().battle_completed(matchup) - - def update_battle_data(self, matchup: Matchup, data: Battle.UiData) -> None: - """Passes new custom battle data to the Ui.""" - self.battle_data[matchup] = data - - def start_fight(self, matchup: Matchup, max_size: int) -> None: - """Informs the Ui of a newly started fight.""" - self.fight_data[matchup] = _FightUiData(max_size) - - def end_fight(self, matchup: Matchup) -> None: # noqa: D102 - del self.fight_data[matchup] - - def start_program(self, matchup: Matchup, role: Role, data: RunningTimer) -> None: # noqa: D102 - match role: - case Role.generator: - self.fight_data[matchup].generator = data - case Role.solver: - self.fight_data[matchup].solver = data - - def end_program(self, matchup: Matchup, role: Role, runtime: float) -> None: # noqa: D102 - match role: - case Role.generator: - self.fight_data[matchup].gen_runtime = runtime - case Role.solver: - self.fight_data[matchup].sol_runtime = runtime - - async def loop(self) -> None: - """Periodically updates the Ui with the current match info.""" - while True: - self.update() - await sleep(0.1) - - @check_for_terminal - def update(self) -> None: - """Disaplys the current status of the match to the cli.""" - terminal_height, _ = self.stdscr.getmaxyx() - out: list[str] = [] - out.append(f"Algobattle version {pkg_version('algobattle_base')}") - status = self.build_status - if isinstance(status, str): - out.append(status) - elif isinstance(status, _BuildInfo): - runtime = (datetime.now() - status.start).total_seconds() - status_str = f"Building {status.role.name} of team {status.team}: {runtime:3.1f}s" - if status.timeout is not None: - status_str += f" / {status.timeout:3.1f}s" - out.append(status_str) - - if self.match is not None: - out += self.display_match(self.match) - for matchup in self.active_battles: - out += [ - "", - ] + self.display_battle(matchup) - - if len(out) > terminal_height: - out = out[:terminal_height] - self.stdscr.clear() - self.stdscr.addstr(0, 0, "\n".join(out)) - self.stdscr.refresh() - self.stdscr.nodelay(True) - - # on windows curses swallows the ctrl+C event, we need to manually check for the control sequence - c = self.stdscr.getch() - if c == 3: - raise KeyboardInterrupt +class _BuildState: + overall_progress: Progress + overall_task: TaskID + team_progress: Progress + team_tasks: dict[str, TaskID] + group: Group + + +class TimerTotalColumn(ProgressColumn): + """Renders time elapsed.""" + + def render(self, task: Task) -> Text: + """Show time elapsed.""" + if not task.started: + return Text("") + elapsed = task.finished_time if task.finished else task.elapsed + total = f" / {task.fields['total_time']}" if "total_time" in task.fields else "" + current = f"{elapsed:.1f}" if elapsed is not None else "" + return Text(current + total, style="progress.elapsed") + + +class LazySpinnerColumn(SpinnerColumn): + """Spinner that only starts once the task starts.""" + + @override + def render(self, task: Task) -> RenderableType: + if not task.started: + return " " + return super().render(task) + + +class FightPanel(Panel): + """Panel displaying a currently running fight.""" + + def __init__(self, max_size: int) -> None: + self.max_size = max_size + self.progress = Progress( + TextColumn("[progress.description]{task.description}"), + LazySpinnerColumn(), + TimerTotalColumn(), + TextColumn("{task.fields[message]}"), + transient=True, + ) + self.generator = self.progress.add_task("Generator", start=False, total=1, message="") + self.solver = self.progress.add_task("Solver", start=False, total=1, message="") + super().__init__(self.progress, title="Current Fight", width=35) + + +class BattlePanel(Panel): + """Panel that displays the state of a battle.""" + + def __init__(self, matchup: Matchup) -> None: + self.matchup = matchup + self._battle_data: RenderableType = "" + self._curr_fight: FightPanel | Literal[""] = "" + self._past_fights = self._fights_table() + super().__init__(self._make_renderable(), title=f"Battle {self.matchup}") + + def _make_renderable(self) -> RenderableType: + return Group( + Columns((self._battle_data, self._curr_fight), expand=True, equal=True, column_first=True, align="center"), + self._past_fights, + ) + + @property + def battle_data(self) -> RenderableType: + return self._battle_data + + @battle_data.setter + def battle_data(self, value: RenderableType) -> None: + self._battle_data = value + self.renderable = self._make_renderable() + + @property + def curr_fight(self) -> FightPanel | Literal[""]: + return self._curr_fight + + @curr_fight.setter + def curr_fight(self, value: FightPanel | Literal[""]) -> None: + self._curr_fight = value + self.renderable = self._make_renderable() + + @property + def past_fights(self) -> Table: + return self._past_fights + + @past_fights.setter + def past_fights(self, value: Table) -> None: + self._past_fights = value + self.renderable = self._make_renderable() + + def _fights_table(self) -> Table: + return Table( + Column("Fight", justify="right"), + Column("Max size", justify="right"), + Column("Score", justify="right"), + "Detail", + title="Most recent fights", + ) + + +class CliUi(Live, Ui): + """Ui that uses rich to draw to the console.""" + + def __init__(self) -> None: + self.match = None + self.build: _BuildState | None = None + self.battle_panels: dict[Matchup, BattlePanel] = {} + super().__init__(None, refresh_per_second=10, transient=False) + + def __enter__(self) -> Self: + return cast(Self, super().__enter__()) + + def _update_renderable(self) -> None: + if self.build: + r = self.build.group else: - curses.flushinp() + assert self.match is not None + r = Group(self.display_match(self.match), *self.battle_panels.values()) + self.update(Panel(r, title=f"[orange1]Algobattle {pkg_version('algobattle_base')}")) @staticmethod - def display_match(match: Match) -> list[str]: + def display_match(match: Match) -> RenderableType: """Formats the match data into a table that can be printed to the terminal.""" - table = PrettyTable(field_names=["Generator", "Solver", "Result"], min_width=5) - table.set_style(DOUBLE_BORDER) - table.align["Result"] = "r" - + table = Table( + Column("Generating", justify="center"), + Column("Solving", justify="center"), + Column("Result", justify="right"), + title="[blue]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 = "Error!" - table.add_row([generating, solving, res]) - - return str(table).split("\n") - - @staticmethod - def display_program(role: Role, timer: RunningTimer | None, runtime: float | None) -> str: - """Formats program runtime data.""" - role_str = role.name.capitalize() + ": " - out = f"{role_str: <11}" - - if timer is None: - return out - - if runtime is None: - # currently running fight - runtime = (datetime.now() - timer.start).total_seconds() - state_glyph = "…" - else: - runtime = runtime - state_glyph = "✓" - - out += f"{runtime:3.1f}s" - if timer.timeout is None: - out += " " # same length padding as timeout info string - else: - out += f" / {timer.timeout:3.1f}s" + res = ":warning:" + table.add_row(generating, solving, res) + return table + + @override + def start_build_step(self, teams: Iterable[str], timeout: float | None) -> None: + team_dict: dict[str, Any] = {t: "none" for t in teams} + overall_progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + transient=True, + ) + team_progress = Progress( + TextColumn("[cyan]{task.fields[name]}"), + TimeElapsedColumn(), + SpinnerColumn(), + TextColumn("{task.fields[failed]}"), + ) + group = Group(overall_progress, team_progress) + overall = overall_progress.add_task("[blue]Building team programs", total=len(team_dict)) + + team_tasks = {} + for team in team_dict: + team_tasks[team] = team_progress.add_task(team, start=False, total=3, failed="", name=team) + + self.build = _BuildState(overall_progress, overall, team_progress, team_tasks, group) + self._update_renderable() + + @override + def start_build(self, team: str, role: Role) -> None: + if self.build is not None: + task = self.build.team_tasks[team] + self.build.team_progress.start_task(task) + self.build.team_progress.advance(task) + + @override + def finish_build(self, team: str, success: bool) -> None: + if self.build is not None: + task = self.build.team_tasks[team] + self.build.team_progress.update(task, completed=3, failed="" if success else ":warning:") + self.build.overall_progress.advance(self.build.overall_task) + + @override + def start_battles(self) -> None: + self.build = None + self._update_renderable() + + @override + def start_battle(self, matchup: Matchup) -> None: + self.battle_panels[matchup] = BattlePanel(matchup) + self._update_renderable() + + @override + def battle_completed(self, matchup: Matchup) -> None: + del self.battle_panels[matchup] + self._update_renderable() - out += f" {state_glyph}" - return out + @override + def start_fight(self, matchup: Matchup, max_size: int) -> None: + self.battle_panels[matchup].curr_fight = FightPanel(max_size) + + @override + def end_fight(self, matchup: Matchup) -> None: + assert self.match is not None + battle = self.match.battle(matchup) + assert battle is not None + fights = battle.fights[-1:-6:-1] + panel = self.battle_panels[matchup] + table = panel._fights_table() + for i, fight in zip(range(len(battle.fights), len(battle.fights) - len(fights), -1), fights): + if fight.generator.error: + info = f"Generator failed: {fight.generator.error.message}" + elif fight.solver and fight.solver.error: + info = f"Solver failed: {fight.solver.error.message}" + else: + info = "" + table.add_row(str(i), str(fight.max_size), f"{fight.score:.1%}", info) + panel.past_fights = table + + @override + def start_program(self, matchup: Matchup, role: Role, data: RunningTimer) -> None: + fight = self.battle_panels[matchup].curr_fight + assert fight != "" + match role: + case Role.generator: + fight.progress.update(fight.generator, total_time=data.timeout) + fight.progress.start_task(fight.generator) + case Role.solver: + fight.progress.update(fight.solver, total_time=data.timeout) + fight.progress.start_task(fight.solver) - @staticmethod - def display_current_fight(fight: _FightUiData) -> list[str]: - """Formats the current fight of a battle into a compact overview.""" - return [ - f"Current fight at size {fight.max_size}:", - CliUi.display_program(Role.generator, fight.generator, fight.gen_runtime), - CliUi.display_program(Role.solver, fight.solver, fight.sol_runtime), - ] + @override + def end_program(self, matchup: Matchup, role: Role, runtime: float) -> None: + fight = self.battle_panels[matchup].curr_fight + assert fight != "" + match role: + case Role.generator: + fight.progress.update(fight.generator, completed=1, message=":heavy_check_mark:") + case Role.solver: + fight.progress.update(fight.solver, completed=1) - @staticmethod - def display_fight(fight: Fight, index: int) -> str: - """Formats a completed fight into a compact overview.""" - if fight.generator.error is not None: - exec_info = ", generator failed!" - elif fight.solver is not None and fight.solver.error is not None: - exec_info = ", solver failed!" - else: - exec_info = "" - return f"Fight {index} at size {fight.max_size}: {fight.score}{exec_info}" - - def display_battle(self, matchup: Matchup) -> list[str]: - """Formats the battle data into a string that can be printed to the terminal.""" - if self.match is None: - return [] - battle = self.match.results[matchup.generator.name][matchup.solver.name] - fights = battle.fights[-3:] if len(battle.fights) >= 3 else battle.fights - sections: list[list[str]] = [] - - if matchup in self.battle_data: - sections.append([f"{key}: {val}" for key, val in self.battle_data[matchup].model_dump().items()]) - - if matchup in self.fight_data: - sections.append(self.display_current_fight(self.fight_data[matchup])) - - if fights: - fight_history: list[str] = [] - for i, fight in enumerate(fights, max(len(battle.fights) - 2, 1)): - fight_history.append(self.display_fight(fight, i)) - fight_display = [ - "Most recent fight results:", - ] + fight_history[::-1] - sections.append(fight_display) - - combined_sections = list(flat_intersperse(sections, "")) - return [ - f"Battle {matchup.generator.name} vs {matchup.solver.name}:", - ] + combined_sections + @override + def update_battle_data(self, matchup: Matchup, data: Battle.UiData) -> None: + self.battle_panels[matchup].battle_data = Group( + "[green]Battle Data:", *(f"[orchid]{key}[/]: [cyan]{value}" for key, value in data.model_dump().items()) + ) if __name__ == "__main__": - main() + app(prog_name="algobattle") diff --git a/algobattle/match.py b/algobattle/match.py index 6aac044e..f30f2c31 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -5,7 +5,8 @@ from itertools import combinations from pathlib import Path import tomllib -from typing import Annotated, Self, cast, overload +from typing import Annotated, Iterable, Protocol, Self, cast, overload +from typing_extensions import override from pydantic import Field from anyio import create_task_group, CapacityLimiter @@ -116,7 +117,7 @@ async def run( A :class:`Match` object with its fields populated to reflect the result of the match. """ if ui is None: - ui = Ui() + ui = EmptyUi() with await TeamHandler.build( config.teams, problem, config.execution.mode, config.match, config.docker, ui @@ -134,6 +135,7 @@ async def run( match_cpus = cast(list[str | None], set_cpus[: config.execution.parallel_battles]) else: match_cpus = [set_cpus] * config.execution.parallel_battles + ui.start_battles() async with create_task_group() as tg: for matchup in teams.matchups: battle = battle_cls() @@ -237,8 +239,7 @@ def calculate_points(self, total_points_per_team: int) -> dict[str, float]: return points -@dataclass -class Ui(BuildUi): +class Ui(BuildUi, Protocol): """Base class for a UI that observes a Match and displays its data. The Ui object both observes the match object as it's being built and receives additional updates through @@ -247,27 +248,30 @@ class Ui(BuildUi): by just subclassing :class:`Ui` and implementing its methods. """ - match: Match | None = field(default=None, init=False) - active_battles: list[Matchup] = field(default_factory=list, init=False) + match: Match | None + + def start_build_step(self, teams: Iterable[str], timeout: float | None) -> None: + """Tells the ui that the build process has started.""" + return - def start_build(self, team: str, role: Role, timeout: float | None) -> None: + def start_build(self, team: str, role: Role) -> None: """Informs the ui that a new program is being built.""" return - def finish_build(self) -> None: + def finish_build(self, team: str, success: bool) -> None: """Informs the ui that the current build has been finished.""" return + def start_battles(self) -> None: + """Tells the UI that building the programs has finished and battles will start now.""" + return + def start_battle(self, matchup: Matchup) -> None: """Notifies the Ui that a battle has been started.""" - self.active_battles.append(matchup) + return def battle_completed(self, matchup: Matchup) -> None: """Notifies the Ui that a specific battle has been completed.""" - self.active_battles.remove(matchup) - - def update_fights(self, matchup: Matchup) -> None: - """Notifies the Ui to update the display of fight results for a specific battle.""" return def update_battle_data(self, matchup: Matchup, data: Battle.UiData) -> None: @@ -296,6 +300,13 @@ def end_program(self, matchup: Matchup, role: Role, runtime: float) -> None: return +@dataclass +class EmptyUi(Ui): + """A dummy Ui.""" + + match: Match | None = field(default=None, init=False) + + @dataclass class BattleObserver(BattleUi, FightUi, ProgramUi): """Tracks updates for a specific battle.""" @@ -303,17 +314,22 @@ class BattleObserver(BattleUi, FightUi, ProgramUi): ui: Ui matchup: Matchup + @override def update_battle_data(self, data: Battle.UiData) -> None: # noqa: D102 self.ui.update_battle_data(self.matchup, data) + @override def start_fight(self, max_size: int) -> None: # noqa: D102 self.ui.start_fight(self.matchup, max_size) + @override def end_fight(self) -> None: # noqa: D102 - self.ui.update_fights(self.matchup) + self.ui.end_fight(self.matchup) + @override def start_program(self, role: Role, timeout: float | None) -> None: # noqa: D102 self.ui.start_program(self.matchup, role, RunningTimer(datetime.now(), timeout)) + @override def stop_program(self, role: Role, runtime: float) -> None: # noqa: D102 self.ui.end_program(self.matchup, role, runtime) diff --git a/algobattle/team.py b/algobattle/team.py index 891f482e..44e5812c 100644 --- a/algobattle/team.py +++ b/algobattle/team.py @@ -2,7 +2,7 @@ from abc import abstractmethod from dataclasses import dataclass, field from itertools import combinations -from typing import Annotated, Any, Iterator, Protocol, Self, TypeAlias +from typing import Annotated, Any, Iterable, Iterator, Protocol, Self, TypeAlias from pydantic import BaseModel, Field @@ -18,11 +18,15 @@ class BuildUi(Protocol): """Provides and interface for the build process to update the ui.""" @abstractmethod - def start_build(self, team: str, role: Role, timeout: float | None) -> None: + def start_build_step(self, teams: Iterable[str], timeout: float | None) -> None: + """Tells the ui that the build process has started.""" + + @abstractmethod + def start_build(self, team: str, role: Role) -> None: """Informs the ui that a new program is being built.""" @abstractmethod - def finish_build(self) -> None: + def finish_build(self, team: str, success: bool) -> None: """Informs the ui that the current build has been finished.""" @@ -60,7 +64,7 @@ async def build( if name in _team_names: raise ValueError tag_name = name.lower().replace(" ", "_") if name_programs else None - ui.start_build(name, Role.generator, match_config.build_timeout) + ui.start_build(name, Role.generator) generator = await Generator.build( path=self.generator, problem=problem, @@ -71,9 +75,8 @@ async def build( max_size=match_config.image_size, team_name=tag_name, ) - ui.finish_build() try: - ui.start_build(name, Role.solver, match_config.build_timeout) + ui.start_build(name, Role.solver) solver = await Solver.build( path=self.solver, problem=problem, @@ -84,7 +87,6 @@ async def build( max_size=match_config.image_size, team_name=tag_name, ) - ui.finish_build() except Exception: generator.remove() raise @@ -153,6 +155,9 @@ def __iter__(self) -> Iterator[Team]: def __repr__(self) -> str: return f"Matchup({self.generator.name}, {self.solver.name})" + def __str__(self) -> str: + return f"{self.generator.name} vs {self.solver.name}" + @dataclass class TeamHandler: @@ -187,12 +192,16 @@ async def build( :class:`TeamHandler` containing the info about the participating teams. """ handler = cls(cleanup=mode == "tournament") + ui.start_build_step(infos.keys(), match_config.build_timeout) for name, info in infos.items(): try: team = await info.build(name, problem, match_config, docker_config, mode == "testing", ui) handler.active.append(team) except Exception as e: handler.excluded[name] = ExceptionInfo.from_exception(e) + ui.finish_build(name, False) + else: + ui.finish_build(name, True) return handler def __enter__(self) -> Self: diff --git a/pyproject.toml b/pyproject.toml index 19f9ace3..b9d202ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,10 @@ classifiers = [ ] dependencies = [ "docker>=6.1.2", - "prettytable>=3.7.0", "pydantic>=2.0.3", "anyio>=3.6.2", - # on windows, we need to manually install curses bindings - "windows-curses>=2.3.1; os_name == 'nt'", + "typer[all]>=0.9.0", + "typing-extensions>=4.7.1", ] [project.optional-dependencies] dev = [ @@ -40,7 +39,7 @@ dev = [ ] [project.scripts] -algobattle = "algobattle.cli:main" +algobattle = "algobattle.cli:app" [tool.pyright] diagnosticMode = "workspace" diff --git a/tests/test_match.py b/tests/test_match.py index dda87979..a311874b 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -6,7 +6,6 @@ from pydantic import ByteSize, ValidationError -from algobattle.cli import parse_cli_args from algobattle.battle import Fight, Iterated, Averaged from algobattle.match import BaseConfig, Match, MatchConfig from algobattle.team import Team, Matchup, TeamHandler, TeamInfo @@ -214,15 +213,15 @@ def setUpClass(cls) -> None: cls.teams = {"team_0": TeamInfo(generator=cls.problem_path / "generator", solver=cls.problem_path / "solver")} def test_no_cfg_default(self): - _, cfg = parse_cli_args([str(self.problem_path)]) + cfg = BaseConfig.from_file(self.problem_path) self.assertEqual(cfg, BaseConfig(teams=self.teams, match=MatchConfig(problem=self.problem_path / "problem.py"))) def test_empty_cfg(self): with self.assertRaises(ValidationError): - parse_cli_args([str(self.configs_path / "empty.toml")]) + BaseConfig.from_file(self.problem_path / "empty.toml") def test_cfg(self): - _, cfg = parse_cli_args([str(self.configs_path / "test.toml")]) + cfg = BaseConfig.from_file(self.problem_path / "test.toml") self.assertEqual( cfg, BaseConfig( @@ -238,16 +237,8 @@ def test_cfg(self): ), ) - def test_cli(self): - exec_config, _ = parse_cli_args([str(self.problem_path), "-s"]) - self.assertTrue(exec_config.silent) - - def test_cli_no_problem_path(self): - with self.assertRaises(SystemExit): - parse_cli_args([]) - def test_cfg_team(self): - _, cfg = parse_cli_args([str(self.configs_path / "teams.toml")]) + cfg = BaseConfig.from_file(self.problem_path / "teams.toml") self.assertEqual( cfg, BaseConfig( @@ -263,7 +254,7 @@ def test_cfg_team(self): def test_cfg_team_no_name(self): with self.assertRaises(ValueError): - parse_cli_args([str(self.configs_path / "teams_incorrect.toml")]) + BaseConfig.from_file(self.problem_path / "teams_incorrect.toml") if __name__ == "__main__": From 71d2c8c32ba715acde57a7b010a723a8da92c781 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 9 Sep 2023 07:05:03 +0200 Subject: [PATCH 002/113] simplify config parsing --- algobattle/cli.py | 4 +--- algobattle/match.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index c678279e..a7c63b85 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -61,9 +61,7 @@ def run( ), ] = None, ) -> Match: - if path.is_dir(): - path /= "config.toml" - + """Runs a match using the config found at the provided path and displays it to the cli.""" config = BaseConfig.from_file(path) problem = Problem.get(config.match.problem) diff --git a/algobattle/match.py b/algobattle/match.py index f30f2c31..4177393b 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -37,21 +37,22 @@ class BaseConfig(BaseModel): docker: DockerConfig = Field(default_factory=dict, validate_default=True) @classmethod - def from_file(cls, file: Path) -> Self: - """Parses a config object from a toml file. - - If the file doesn't exist it returns a default instance instead of raising an error. - """ - if not file.is_file(): + def from_file(cls, source: Path) -> Self: + """Parses a config object from a toml file.""" + if not source.exists(): + raise ValueError + if source.is_dir() and (source / "config.toml").is_file(): + source /= "config.toml" + if not source.is_file(): config_dict = {} else: - with open(file, "rb") as f: + with open(source, "rb") as f: try: config_dict = tomllib.load(f) except tomllib.TOMLDecodeError as e: - raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}") + raise ValueError(f"The config file at {source} is not a properly formatted TOML file!\n{e}") Battle.load_entrypoints() - return cls.model_validate(config_dict, context={"base_path": file.parent}) + return cls.model_validate(config_dict, context={"base_path": source.parent}) class Match(BaseModel): From bd775b3ffdd160ce726a325ef60fc9c9d02116c4 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 9 Sep 2023 07:31:42 +0200 Subject: [PATCH 003/113] cleanup --- algobattle/cli.py | 51 ++++++++++++++++++++------------------------- algobattle/match.py | 37 ++++++++++++++++---------------- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index a7c63b85..8637239f 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -2,7 +2,6 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ -from contextlib import ExitStack from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -11,7 +10,7 @@ from importlib.metadata import version as pkg_version from anyio import run as run_async_fn -from typer import Typer, Argument, Option +from typer import Exit, Typer, Argument, Option from rich.console import Group, RenderableType, Console from rich.live import Live from rich.table import Table, Column @@ -31,7 +30,7 @@ from rich.columns import Columns from algobattle.battle import Battle -from algobattle.match import BaseConfig, Match, Ui +from algobattle.match import BaseConfig, EmptyUi, Match, Ui from algobattle.problem import Problem from algobattle.team import Matchup from algobattle.util import Role, RunningTimer @@ -64,32 +63,28 @@ def run( """Runs a match using the config found at the provided path and displays it to the cli.""" config = BaseConfig.from_file(path) problem = Problem.get(config.match.problem) - + result = Match() try: - result = None - with ExitStack() as stack: - if ui: - ui_obj = stack.enter_context(CliUi()) - else: - ui_obj = None - result = run_async_fn(Match.run, config, problem, ui_obj) - assert result is not None - - console.print(CliUi.display_match(result)) - if config.execution.points > 0: - points = result.calculate_points(config.execution.points) - for team, pts in points.items(): - print(f"Team {team} gained {pts:.1f} points.") - - if result_path is not None: - t = datetime.now() - filename = f"{t.year:04d}-{t.month:02d}-{t.day:02d}_{t.hour:02d}-{t.minute:02d}-{t.second:02d}.json" - with open(result_path / filename, "w+") as f: - f.write(result.model_dump_json(exclude_defaults=True)) - return result - + with CliUi() if ui else EmptyUi() as ui_obj: + run_async_fn(result.run, config, problem, ui_obj) except KeyboardInterrupt: - raise SystemExit("Received keyboard interrupt, terminating execution.") + console.print("Received keyboard interrupt, terminating execution.") + finally: + try: + console.print(CliUi.display_match(result)) + if config.execution.points > 0: + points = result.calculate_points(config.execution.points) + for team, pts in points.items(): + print(f"Team {team} gained {pts:.1f} points.") + + if result_path is not None: + t = datetime.now() + filename = f"{t.year:04d}-{t.month:02d}-{t.day:02d}_{t.hour:02d}-{t.minute:02d}-{t.second:02d}.json" + with open(result_path / filename, "w+") as f: + f.write(result.model_dump_json(exclude_defaults=True)) + return result + except KeyboardInterrupt: + raise Exit @dataclass @@ -201,7 +196,7 @@ def __init__(self) -> None: self.match = None self.build: _BuildState | None = None self.battle_panels: dict[Matchup, BattlePanel] = {} - super().__init__(None, refresh_per_second=10, transient=False) + super().__init__(None, refresh_per_second=10, transient=True) def __enter__(self) -> Self: return cast(Self, super().__enter__()) diff --git a/algobattle/match.py b/algobattle/match.py index 4177393b..d00eea35 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -5,7 +5,7 @@ from itertools import combinations from pathlib import Path import tomllib -from typing import Annotated, Iterable, Protocol, Self, cast, overload +from typing import Annotated, Any, Iterable, Protocol, Self, cast, overload from typing_extensions import override from pydantic import Field @@ -41,7 +41,7 @@ def from_file(cls, source: Path) -> Self: """Parses a config object from a toml file.""" if not source.exists(): raise ValueError - if source.is_dir() and (source / "config.toml").is_file(): + if source.is_dir(): source /= "config.toml" if not source.is_file(): config_dict = {} @@ -58,8 +58,8 @@ def from_file(cls, source: Path) -> Self: class Match(BaseModel): """The Result of a whole Match.""" - active_teams: list[str] - excluded_teams: dict[str, ExceptionInfo] + 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) ) @@ -98,13 +98,12 @@ async def _run_battle( cpus.append(set_cpus) ui.battle_completed(matchup) - @classmethod async def run( - cls, + self, config: BaseConfig, problem: Problem[InstanceT, SolutionT], ui: "Ui | None" = None, - ) -> Self: + ) -> None: """Runs a match with the given config settings and problem type. The first step is building the docker images for each team in `config.teams`. Any teams where this process fails @@ -113,21 +112,16 @@ async def run( Since all of these battles are completely independent, you can set `config.parallel_battles` to have some number of them run in parallel. This will speed up the exection of the match, but can also make the match unfair if the hardware running it does not have the resources to adequately execute that many containers in parallel. - - Returns: - A :class:`Match` object with its fields populated to reflect the result of the match. """ if ui is None: ui = EmptyUi() + ui.match = self with await TeamHandler.build( config.teams, problem, config.execution.mode, config.match, config.docker, ui ) as teams: - result = cls( - active_teams=[t.name for t in teams.active], - excluded_teams=teams.excluded, - ) - ui.match = result + self.active_teams = [t.name for t in teams.active] + self.excluded_teams = teams.excluded battle_cls = Battle.all()[config.battle.type] limiter = CapacityLimiter(config.execution.parallel_battles) current_default_thread_limiter().total_tokens = config.execution.parallel_battles @@ -140,9 +134,8 @@ async def run( async with create_task_group() as tg: for matchup in teams.matchups: battle = battle_cls() - result.results[matchup.generator.name][matchup.solver.name] = battle - tg.start_soon(result._run_battle, battle, matchup, config, problem, match_cpus, ui, limiter) - return result + self.results[matchup.generator.name][matchup.solver.name] = battle + tg.start_soon(self._run_battle, battle, matchup, config, problem, match_cpus, ui, limiter) @overload def battle(self, matchup: Matchup) -> Battle | None: @@ -307,6 +300,14 @@ class EmptyUi(Ui): match: Match | None = field(default=None, init=False) + def __enter__(self) -> Self: + """Starts displaying the Ui.""" + return self + + def __exit__(self, *args: Any) -> None: + """Stops the Ui.""" + return + @dataclass class BattleObserver(BattleUi, FightUi, ProgramUi): From ae46a6555f5b9f5b2558d94661bda82f8e6c3bf4 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 9 Sep 2023 07:32:45 +0200 Subject: [PATCH 004/113] update tests --- tests/test_match.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_match.py b/tests/test_match.py index a311874b..2177bc52 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -168,7 +168,8 @@ def setUpClass(cls) -> None: async def test_basic(self): self.config_iter.teams = {"team_0": TeamInfo(generator=self.generator, solver=self.solver)} - res = await Match.run(self.config_iter, TestProblem) + res = Match() + await res.run(self.config_iter, TestProblem) for res_dict in res.results.values(): for result in res_dict.values(): self.assertIsNone(result.run_exception) @@ -181,7 +182,8 @@ async def test_multi_team(self): team0 = TeamInfo(generator=self.generator, solver=self.solver) team1 = TeamInfo(generator=self.generator, solver=self.solver) self.config_iter.teams = {"team_0": team0, "team_1": team1} - res = await Match.run(self.config_iter, TestProblem) + res = Match() + await res.run(self.config_iter, TestProblem) for res_dict in res.results.values(): for result in res_dict.values(): self.assertIsNone(result.run_exception) @@ -192,7 +194,8 @@ async def test_multi_team(self): async def test_averaged(self): self.config_avg.teams = {"team_0": TeamInfo(generator=self.generator, solver=self.solver)} - res = await Match.run(self.config_avg, TestProblem) + res = Match() + await res.run(self.config_avg, TestProblem) for res_dict in res.results.values(): for result in res_dict.values(): self.assertIsNone(result.run_exception) From 51fada54e94dc652ebe2d0795740fee42f1b34e1 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 9 Sep 2023 07:35:00 +0200 Subject: [PATCH 005/113] fix config parsing tests --- tests/test_match.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_match.py b/tests/test_match.py index 2177bc52..9f636478 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -221,10 +221,10 @@ def test_no_cfg_default(self): def test_empty_cfg(self): with self.assertRaises(ValidationError): - BaseConfig.from_file(self.problem_path / "empty.toml") + BaseConfig.from_file(self.configs_path / "empty.toml") def test_cfg(self): - cfg = BaseConfig.from_file(self.problem_path / "test.toml") + cfg = BaseConfig.from_file(self.configs_path / "test.toml") self.assertEqual( cfg, BaseConfig( @@ -241,7 +241,7 @@ def test_cfg(self): ) def test_cfg_team(self): - cfg = BaseConfig.from_file(self.problem_path / "teams.toml") + cfg = BaseConfig.from_file(self.configs_path / "teams.toml") self.assertEqual( cfg, BaseConfig( @@ -257,7 +257,7 @@ def test_cfg_team(self): def test_cfg_team_no_name(self): with self.assertRaises(ValueError): - BaseConfig.from_file(self.problem_path / "teams_incorrect.toml") + BaseConfig.from_file(self.configs_path / "teams_incorrect.toml") if __name__ == "__main__": From 923b119cd5293e2732ea40ae138bc538003e361e Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 9 Sep 2023 08:11:38 +0200 Subject: [PATCH 006/113] make build view a Renderable --- algobattle/cli.py | 96 +++++++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 8637239f..7ddbda06 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -2,10 +2,9 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ -from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Annotated, Any, Iterable, Literal, Optional, Self, cast +from typing import Annotated, Iterable, Literal, Optional, Self, cast from typing_extensions import override from importlib.metadata import version as pkg_version @@ -87,15 +86,6 @@ def run( raise Exit -@dataclass -class _BuildState: - overall_progress: Progress - overall_task: TaskID - team_progress: Progress - team_tasks: dict[str, TaskID] - group: Group - - class TimerTotalColumn(ProgressColumn): """Renders time elapsed.""" @@ -119,6 +109,31 @@ def render(self, task: Task) -> RenderableType: return super().render(task) +class BuildView(Group): + """Displays the build process.""" + + def __init__(self, teams: Iterable[str]) -> None: + teams = list(teams) + self.overall_progress = Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + transient=True, + ) + self.team_progress = Progress( + TextColumn("[cyan]{task.fields[name]}"), + LazySpinnerColumn(), + BarColumn(bar_width=10), + TimeElapsedColumn(), + ) + self.overall_task = self.overall_progress.add_task("[blue]Building programs", total=2 * len(teams)) + team_dict: dict[str, TaskID] = {} + for team in teams: + team_dict[team] = self.team_progress.add_task(team, start=False, total=2, failed="", name=team) + self.teams = team_dict + super().__init__(self.overall_progress, self.team_progress) + + class FightPanel(Panel): """Panel displaying a currently running fight.""" @@ -133,7 +148,7 @@ def __init__(self, max_size: int) -> None: ) self.generator = self.progress.add_task("Generator", start=False, total=1, message="") self.solver = self.progress.add_task("Solver", start=False, total=1, message="") - super().__init__(self.progress, title="Current Fight", width=35) + super().__init__(self.progress, title="Current Fight", width=30) class BattlePanel(Panel): @@ -148,7 +163,7 @@ def __init__(self, matchup: Matchup) -> None: def _make_renderable(self) -> RenderableType: return Group( - Columns((self._battle_data, self._curr_fight), expand=True, equal=True, column_first=True, align="center"), + Columns((self._battle_data, self._curr_fight), expand=True, equal=True, align="center"), self._past_fights, ) @@ -194,20 +209,17 @@ class CliUi(Live, Ui): def __init__(self) -> None: self.match = None - self.build: _BuildState | None = None self.battle_panels: dict[Matchup, BattlePanel] = {} super().__init__(None, refresh_per_second=10, transient=True) def __enter__(self) -> Self: return cast(Self, super().__enter__()) - def _update_renderable(self) -> None: - if self.build: - r = self.build.group - else: + def _update_renderable(self, renderable: RenderableType | None = None) -> None: + if renderable is None: assert self.match is not None - r = Group(self.display_match(self.match), *self.battle_panels.values()) - self.update(Panel(r, title=f"[orange1]Algobattle {pkg_version('algobattle_base')}")) + renderable = Group(self.display_match(self.match), *self.battle_panels.values()) + self.update(Panel(renderable, title=f"[orange1]Algobattle {pkg_version('algobattle_base')}")) @staticmethod def display_match(match: Match) -> RenderableType: @@ -229,42 +241,26 @@ def display_match(match: Match) -> RenderableType: @override def start_build_step(self, teams: Iterable[str], timeout: float | None) -> None: - team_dict: dict[str, Any] = {t: "none" for t in teams} - overall_progress = Progress( - TextColumn("[progress.description]{task.description}"), - BarColumn(), - MofNCompleteColumn(), - transient=True, - ) - team_progress = Progress( - TextColumn("[cyan]{task.fields[name]}"), - TimeElapsedColumn(), - SpinnerColumn(), - TextColumn("{task.fields[failed]}"), - ) - group = Group(overall_progress, team_progress) - overall = overall_progress.add_task("[blue]Building team programs", total=len(team_dict)) - - team_tasks = {} - for team in team_dict: - team_tasks[team] = team_progress.add_task(team, start=False, total=3, failed="", name=team) - - self.build = _BuildState(overall_progress, overall, team_progress, team_tasks, group) - self._update_renderable() + self._update_renderable(BuildView(teams)) @override def start_build(self, team: str, role: Role) -> None: - if self.build is not None: - task = self.build.team_tasks[team] - self.build.team_progress.start_task(task) - self.build.team_progress.advance(task) + assert isinstance(self.renderable, Panel) + view = self.renderable.renderable + assert isinstance(view, BuildView) + task = view.teams[team] + view.team_progress.start_task(task) + view.team_progress.advance(task) @override def finish_build(self, team: str, success: bool) -> None: - if self.build is not None: - task = self.build.team_tasks[team] - self.build.team_progress.update(task, completed=3, failed="" if success else ":warning:") - self.build.overall_progress.advance(self.build.overall_task) + assert isinstance(self.renderable, Panel) + view = self.renderable.renderable + assert isinstance(view, BuildView) + task = view.teams[team] + current = view.team_progress._tasks[task].completed + view.team_progress.update(task, completed=2, failed="" if success else ":warning:") + view.overall_progress.advance(view.overall_task, 2 - current) @override def start_battles(self) -> None: From 12992948850b07b097eb8a84949d08d7908b6dde Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 9 Sep 2023 17:24:42 +0200 Subject: [PATCH 007/113] add basic init command --- .gitignore | 1 + algobattle/cli.py | 52 +++++++++++++++++-- algobattle/templates/__init__.py | 43 +++++++++++++++ algobattle/templates/python/Dockerfile | 9 ++++ algobattle/templates/python/pyproject.toml | 14 +++++ .../templates/python/{{program}}/main.py | 24 +++++++++ pyproject.toml | 2 + 7 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 algobattle/templates/__init__.py create mode 100644 algobattle/templates/python/Dockerfile create mode 100644 algobattle/templates/python/pyproject.toml create mode 100644 algobattle/templates/python/{{program}}/main.py diff --git a/.gitignore b/.gitignore index 2683c02c..a0eefec0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ site docs/src/pairsum_solver/target docs/src/pairsum_solver/Cargo.lock .results +.project diff --git a/algobattle/cli.py b/algobattle/cli.py index 7ddbda06..839b041c 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -3,13 +3,17 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ from datetime import datetime +from enum import StrEnum +from importlib.abc import Traversable +from importlib.resources import files from pathlib import Path -from typing import Annotated, Iterable, Literal, Optional, Self, cast -from typing_extensions import override +from string import Template +from typing import Annotated, Any, Iterable, Iterator, Literal, Optional, Self, cast +from typing_extensions import TypedDict, override from importlib.metadata import version as pkg_version from anyio import run as run_async_fn -from typer import Exit, Typer, Argument, Option +from typer import Exit, Typer, Argument, Option, Abort from rich.console import Group, RenderableType, Console from rich.live import Live from rich.table import Table, Column @@ -33,6 +37,7 @@ from algobattle.problem import Problem from algobattle.team import Matchup from algobattle.util import Role, RunningTimer +from algobattle.templates import Language, TemplateArgs, write_templates __all__ = ("app",) @@ -86,6 +91,47 @@ def run( raise Exit +@app.command() +def init( + target: Annotated[ + Path, Argument(exists=True, file_okay=False, writable=True, help="The folder to initialize.") + ] = Path(), + problem: Annotated[ + Optional[Path], + Option("--problem", "-p", exists=True, dir_okay=False, help="A problem spec zip file to use for this."), + ] = None, + language: Annotated[ + Optional[Language], Option("--language", "-l", help="The language to use for the programs.") + ] = None, + generator: Annotated[ + Optional[Language], Option("--generator", "-g", help="The language to use for the generator.") + ] = None, + solver: Annotated[Optional[Language], Option("--solver", "-s", help="The language to use for the solver.")] = None, +) -> None: + """Initializes a project directory, setting up the problem files and program folders with docker files. + + Generates dockerfiles and an initial project structure for the language(s) you choose. Either use `--language` to + use the same language for both, or specify each individually with `--generator` and `--solver`. + """ + if language is not None and (generator is not None or solver is not None): + console.print("You cannot use both `--language` and `--generator`/`--solver` at the same time.") + raise Abort() + if language: + generator = solver = language + template_args: TemplateArgs = { + "program": "generator", + "problem": "Blep", + "team": "Wahs", + } + + if generator is not None: + write_templates(target / "generator", generator, template_args) + + template_args["program"] = "solver" + if solver is not None: + write_templates(target / "solver", solver, template_args) + + class TimerTotalColumn(ProgressColumn): """Renders time elapsed.""" diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py new file mode 100644 index 00000000..9f760f16 --- /dev/null +++ b/algobattle/templates/__init__.py @@ -0,0 +1,43 @@ +"""Package containing templates used for the algobattle init command.""" + +from enum import StrEnum +from pathlib import Path +from typing import Literal +from typing_extensions import TypedDict +from jinja2 import Environment, PackageLoader, Template + + +class Language(StrEnum): + """Langues supported by `algobattle init`.""" + + python = "python" + + +ENVS = { + "python": Environment( + loader=PackageLoader("algobattle.templates", "python"), keep_trailing_newline=True, line_statement_prefix="# ?" + ) +} + + +class TemplateArgs(TypedDict): + """Template arguments.""" + + problem: str + team: str + program: Literal["generator", "solver"] + + +def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: + """Yields all templates and where they should be placed.""" + template_args = args | { + "project": f"{args['team'].lower()}-{args['problem'].lower()}-{args['program'].lower()}", + } + env = ENVS[lang] + for name in env.list_templates(): + template = env.get_template(name) + formatted = template.render(template_args) + formatted_path = Template(name).render(template_args) + (target / formatted_path).parent.mkdir(parents=True, exist_ok=True) + with open(target / formatted_path, "w+") as file: + file.write(formatted) diff --git a/algobattle/templates/python/Dockerfile b/algobattle/templates/python/Dockerfile new file mode 100644 index 00000000..cca1e0b3 --- /dev/null +++ b/algobattle/templates/python/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11 + +WORKDIR /algobattle_src +COPY pyproject.toml . +COPY {{ program }} {{ program }} +RUN pip install . + +WORKDIR / +CMD [ "python", "-m", "{{ program }}.main" ] diff --git a/algobattle/templates/python/pyproject.toml b/algobattle/templates/python/pyproject.toml new file mode 100644 index 00000000..ef966bc6 --- /dev/null +++ b/algobattle/templates/python/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[project] +name = "{{ project }}" +version = "0.0.0" +description = "{{program.capitalize()}} for the {{ problem }} problem." +requires-python = ">=3.11" +authors = [{name = "{{ team }}"}] + +dependencies = [ + # list your dependencies here +] diff --git a/algobattle/templates/python/{{program}}/main.py b/algobattle/templates/python/{{program}}/main.py new file mode 100644 index 00000000..4d2ec56c --- /dev/null +++ b/algobattle/templates/python/{{program}}/main.py @@ -0,0 +1,24 @@ +"""Main module, will be run as the program.""" +import json + + +# ? if program == "generator" +with open("/input/max_size.txt") as file: + max_size = int(file.read()) + +# ! your code here +instance = {} +solution = {} + +with open("/output/instance.json", "w+") as file: + json.dump(instance, file) +# ? else +with open("/input/instance.json") as file: + instance = json.load(file) + +# ! your code here +solution = {} + +# ? endif +with open("/output/solution.json", "w+") as file: + json.dump(solution, file) diff --git a/pyproject.toml b/pyproject.toml index b9d202ea..1773cce1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "anyio>=3.6.2", "typer[all]>=0.9.0", "typing-extensions>=4.7.1", + "tomlkit>=0.12.1", + "jinja2>=3.1.2", ] [project.optional-dependencies] dev = [ From 291187e4ecb5a5019ef7eae89bad5e77ec297139 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 12 Sep 2023 22:44:41 +0200 Subject: [PATCH 008/113] add config subcommand --- algobattle/cli.py | 74 ++++++++++++++++++++++++++++---- algobattle/templates/__init__.py | 6 ++- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 839b041c..28675e35 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -2,18 +2,17 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ +from dataclasses import dataclass from datetime import datetime -from enum import StrEnum -from importlib.abc import Traversable -from importlib.resources import files from pathlib import Path -from string import Template -from typing import Annotated, Any, Iterable, Iterator, Literal, Optional, Self, cast -from typing_extensions import TypedDict, override +from random import choice +from typing import Annotated, Any, ClassVar, Iterable, Literal, Optional, Self, cast +from typing_extensions import override from importlib.metadata import version as pkg_version +from textwrap import dedent from anyio import run as run_async_fn -from typer import Exit, Typer, Argument, Option, Abort +from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch from rich.console import Group, RenderableType, Console from rich.live import Live from rich.table import Table, Column @@ -31,6 +30,7 @@ from rich.panel import Panel from rich.text import Text from rich.columns import Columns +from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml from algobattle.battle import Battle from algobattle.match import BaseConfig, EmptyUi, Match, Ui @@ -43,10 +43,57 @@ __all__ = ("app",) -app = Typer(pretty_exceptions_show_locals=False) +app = Typer(pretty_exceptions_show_locals=True) console = Console() +@dataclass +class CliConfig: + + doc: TOMLDocument + + path: ClassVar[Path] = Path(get_app_dir("algobattle")) / "config.toml" + + @classmethod + def init_file(cls) -> None: + """Initializes the config file if it does not exist.""" + if not cls.path.is_file(): + cls.path.parent.mkdir(parents=True, exist_ok=True) + text = """ + # The Algobattle cli configuration + + # team_name = "Some string" # the name of the team that you're in + """ + cls.path.write_text(dedent(text)) + + @classmethod + def load(cls) -> Self: + """Parses a config object from a toml file.""" + cls.init_file() + doc = parse_toml(cls.path.read_text()) + return cls(doc) + + def save(self) -> None: + """Saves the config to file.""" + self.path.write_text(dumps_toml(self.doc)) + + @property + def team_name(self) -> str | None: + """Name of the user's team.""" + name: Any = self.doc.get("team_name", None) + if not isinstance(name, str): + raise Abort(f"Bad configuration! Team name must be a string, not {name}.") + return name + + @team_name.setter + def team_name(self, name: str | None) -> None: + if name is None: + if "team_name" in self.doc: + self.doc.remove("team_name") + else: + self.doc["team_name"] = name + + @app.command() def run( path: Annotated[Path, Argument(exists=True, help="Path to either a config file or a directory containing one.")], @@ -118,10 +165,11 @@ def init( raise Abort() if language: generator = solver = language + config = CliConfig.load() template_args: TemplateArgs = { "program": "generator", "problem": "Blep", - "team": "Wahs", + "team": config.team_name or choice(("Dogs", "Cats", "Otters", "Red Pandas", "Possums", "Rats")), } if generator is not None: @@ -132,6 +180,14 @@ def init( write_templates(target / "solver", solver, template_args) +@app.command() +def config() -> None: + """Opens the algobattle cli tool config file.""" + CliConfig.init_file() + print(f"Opening the algobattle cli config file at {CliConfig.path}.") + launch(str(CliConfig.path)) + + class TimerTotalColumn(ProgressColumn): """Renders time elapsed.""" diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 9f760f16..947fdfa9 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -28,10 +28,14 @@ class TemplateArgs(TypedDict): program: Literal["generator", "solver"] +def normalize(s: str) -> str: + return s.lower().replace(" ", "-") + + def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: """Yields all templates and where they should be placed.""" template_args = args | { - "project": f"{args['team'].lower()}-{args['problem'].lower()}-{args['program'].lower()}", + "project": f"{normalize(args['team'])}-{normalize(args['problem'])}-{normalize(args['program'])}", } env = ENVS[lang] for name in env.list_templates(): From 4a03b58eda7bd863cefa3e7a311f639debdc798f Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 12 Sep 2023 22:45:32 +0200 Subject: [PATCH 009/113] cleanup --- algobattle/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 28675e35..be9bf31a 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -49,7 +49,6 @@ @dataclass class CliConfig: - doc: TOMLDocument path: ClassVar[Path] = Path(get_app_dir("algobattle")) / "config.toml" @@ -162,7 +161,7 @@ def init( """ if language is not None and (generator is not None or solver is not None): console.print("You cannot use both `--language` and `--generator`/`--solver` at the same time.") - raise Abort() + raise Abort if language: generator = solver = language config = CliConfig.load() From 35fc7977a5b4ca886f41a6415b605c79a45fa426 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 12 Sep 2023 23:21:57 +0200 Subject: [PATCH 010/113] ask for confirmation before replacing programs --- algobattle/cli.py | 28 ++++++++++++++++++++-------- algobattle/templates/__init__.py | 10 ++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index be9bf31a..766bc0dc 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -12,7 +12,7 @@ from textwrap import dedent from anyio import run as run_async_fn -from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch +from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch, confirm from rich.console import Group, RenderableType, Console from rich.live import Live from rich.table import Table, Column @@ -37,7 +37,7 @@ from algobattle.problem import Problem from algobattle.team import Matchup from algobattle.util import Role, RunningTimer -from algobattle.templates import Language, TemplateArgs, write_templates +from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates __all__ = ("app",) @@ -61,7 +61,7 @@ def init_file(cls) -> None: text = """ # The Algobattle cli configuration - # team_name = "Some string" # the name of the team that you're in + team_name = "" # the name of the team that you're in, must be a non-empty string to be valid """ cls.path.write_text(dedent(text)) @@ -137,6 +137,20 @@ def run( raise Exit +def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: Role) -> None: + dir = target / role.value + if dir.exists(): + replace = confirm(f"The targeted directory already contains a {role}, do you want to replace it?") + if replace: + dir.unlink() + dir.mkdir() + else: + return + else: + dir.mkdir(parents=True, exist_ok=True) + write_templates(dir, lang, cast(TemplateArgs, args | {"program": role.value})) + + @app.command() def init( target: Annotated[ @@ -165,18 +179,16 @@ def init( if language: generator = solver = language config = CliConfig.load() - template_args: TemplateArgs = { - "program": "generator", + template_args: PartialTemplateArgs = { "problem": "Blep", "team": config.team_name or choice(("Dogs", "Cats", "Otters", "Red Pandas", "Possums", "Rats")), } if generator is not None: - write_templates(target / "generator", generator, template_args) + _init_program(target, generator, template_args, Role.generator) - template_args["program"] = "solver" if solver is not None: - write_templates(target / "solver", solver, template_args) + _init_program(target, solver, template_args, Role.solver) @app.command() diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 947fdfa9..93450653 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -20,15 +20,21 @@ class Language(StrEnum): } -class TemplateArgs(TypedDict): - """Template arguments.""" +class PartialTemplateArgs(TypedDict): + """Template arguments without the program role.""" problem: str team: str + + +class TemplateArgs(PartialTemplateArgs): + """Template arguments.""" + program: Literal["generator", "solver"] def normalize(s: str) -> str: + """Normalizes a name so it can be used in project names.""" return s.lower().replace(" ", "-") From faf2d1db030d0a650a26da3944497d33625ed5f4 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 12 Sep 2023 23:53:26 +0200 Subject: [PATCH 011/113] add TempDir helper --- algobattle/program.py | 4 ++-- algobattle/util.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/algobattle/program.py b/algobattle/program.py index 2ef02de6..5527d576 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -38,6 +38,7 @@ RunConfig, RunConfigOverride, RunSpecs, + TempDir, ValidationError, Role, BaseModel, @@ -384,8 +385,7 @@ def _setup_docker_env(cls, source: Path) -> PyGenerator[tuple[Path, str | None], yield source.parent, source.name return - with TemporaryDirectory() as build_folder: - build_folder = Path(build_folder) + with TempDir() as build_folder: if is_zipfile(source): with ZipFile(source, "r") as f: f.extractall(build_folder) diff --git a/algobattle/util.py b/algobattle/util.py index 43958f1f..805e9f5c 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -10,6 +10,7 @@ from itertools import chain import json from pathlib import Path +from tempfile import TemporaryDirectory from traceback import format_exception from types import EllipsisType from typing import Annotated, Any, Callable, ClassVar, Iterable, Literal, LiteralString, TypeVar, Self, cast, get_args @@ -532,3 +533,10 @@ def val_set_cpus(self) -> Self: raise ValueError("Number of parallel battles exceeds the number of set_cpu specifier strings.") else: return self + + +class TempDir(TemporaryDirectory): + """Python's `TemporaryDirectory` but with a contextmanager returning a Path.""" + + def __enter__(self) -> Path: + return Path(super().__enter__()) From 413f59717feda3985bbc747e9bd39fb5f3879675 Mon Sep 17 00:00:00 2001 From: Imogen Date: Wed, 13 Sep 2023 04:08:26 +0200 Subject: [PATCH 012/113] add problem spec parsing to init command --- algobattle/cli.py | 152 +++++++++++++++++++++++++++++++----------- algobattle/match.py | 2 +- algobattle/problem.py | 6 +- algobattle/util.py | 4 +- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 766bc0dc..3fd4ba30 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -4,14 +4,20 @@ """ from dataclasses import dataclass from datetime import datetime +from os import environ from pathlib import Path from random import choice +from shutil import rmtree +from subprocess import PIPE, run as run_process +import sys from typing import Annotated, Any, ClassVar, Iterable, Literal, Optional, Self, cast from typing_extensions import override from importlib.metadata import version as pkg_version from textwrap import dedent +from zipfile import ZipFile from anyio import run as run_async_fn +from pydantic import Field, ValidationError, BaseModel from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch, confirm from rich.console import Group, RenderableType, Console from rich.live import Live @@ -30,13 +36,16 @@ from rich.panel import Panel from rich.text import Text from rich.columns import Columns -from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml +from rich.status import Status +from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table +from tomlkit.items import Table as TomlTable +from tomlkit.container import Container as TomlContainer from algobattle.battle import Battle from algobattle.match import BaseConfig, EmptyUi, Match, Ui from algobattle.problem import Problem from algobattle.team import Matchup -from algobattle.util import Role, RunningTimer +from algobattle.util import ExecutionConfig, Role, RunningTimer, BaseModel as AlgobattleBaseModel, TempDir from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates @@ -47,54 +56,48 @@ console = Console() -@dataclass -class CliConfig: - doc: TOMLDocument +class _General(BaseModel): + team_name: str | None = None + install_command: list[str] = [sys.executable, "-m", "pip", "install"] + +class CliConfig(BaseModel, frozen=True): + general: _General = Field(default_factory=dict, validate_default=True) + execution: ExecutionConfig = Field(default_factory=dict, validate_default=True) + + _doc: TOMLDocument path: ClassVar[Path] = Path(get_app_dir("algobattle")) / "config.toml" + model_config = AlgobattleBaseModel.model_config @classmethod def init_file(cls) -> None: """Initializes the config file if it does not exist.""" if not cls.path.is_file(): cls.path.parent.mkdir(parents=True, exist_ok=True) - text = """ - # The Algobattle cli configuration - - team_name = "" # the name of the team that you're in, must be a non-empty string to be valid - """ - cls.path.write_text(dedent(text)) + cls.path.write_text("# The Algobattle cli configuration\n") @classmethod def load(cls) -> Self: """Parses a config object from a toml file.""" cls.init_file() doc = parse_toml(cls.path.read_text()) - return cls(doc) + self = cls.model_validate(doc) + object.__setattr__(self, "_doc", doc) + return self def save(self) -> None: """Saves the config to file.""" - self.path.write_text(dumps_toml(self.doc)) + self.path.write_text(dumps_toml(self._doc)) @property - def team_name(self) -> str | None: - """Name of the user's team.""" - name: Any = self.doc.get("team_name", None) - if not isinstance(name, str): - raise Abort(f"Bad configuration! Team name must be a string, not {name}.") - return name - - @team_name.setter - def team_name(self, name: str | None) -> None: - if name is None: - if "team_name" in self.doc: - self.doc.remove("team_name") - else: - self.doc["team_name"] = name + def default_exec(self) -> TomlTable | None: + """The default exec config for each problem.""" + exec: Any = self._doc.get("exec", None) + return exec -@app.command() -def run( +@app.command("run") +def run_match( path: Annotated[Path, Argument(exists=True, help="Path to either a config file or a directory containing one.")], ui: Annotated[bool, Option(help="Whether to show the CLI UI during match execution.")] = True, result_path: Annotated[ @@ -140,9 +143,9 @@ def run( def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: Role) -> None: dir = target / role.value if dir.exists(): - replace = confirm(f"The targeted directory already contains a {role}, do you want to replace it?") + replace = confirm(f"The targeted directory already contains a {role}, do you want to replace it?", default=True) if replace: - dir.unlink() + rmtree(dir) dir.mkdir() else: return @@ -154,11 +157,11 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: @app.command() def init( target: Annotated[ - Path, Argument(exists=True, file_okay=False, writable=True, help="The folder to initialize.") - ] = Path(), + Optional[Path], Argument(exists=True, file_okay=False, writable=True, help="The folder to initialize.") + ] = None, problem: Annotated[ Optional[Path], - Option("--problem", "-p", exists=True, dir_okay=False, help="A problem spec zip file to use for this."), + Option("--problem", "-p", exists=True, dir_okay=False, help="A problem spec file to use for this."), ] = None, language: Annotated[ Optional[Language], Option("--language", "-l", help="The language to use for the programs.") @@ -179,16 +182,89 @@ def init( if language: generator = solver = language config = CliConfig.load() + team_name = config.general.team_name or choice(("Dogs", "Cats", "Otters", "Red Pandas", "Possums", "Rats")) + + if problem is None and Path("problem.aprb").is_file(): + problem = Path("problem.aprb") + if problem is not None: + with TempDir() as build_dir: + with Status("Extracting problem data"): + with ZipFile(problem) as problem_zip: + problem_zip.extractall(build_dir) + + problem_config = parse_toml((build_dir / "config.toml").read_text()) + parsed_config = BaseConfig.model_validate(problem_config) # ! paths in this arent properly relativized + problem_name = parsed_config.match.problem + assert isinstance(problem_name, str) + if target is None: + target = Path() if Path().name == problem_name else Path() / problem_name + + new_problem = True + problem_data = list(build_dir.iterdir()) + if any(((target / path.name).exists() for path in problem_data)): + replace = confirm( + "The target directory already contains problem data, do you want to replace it?", default=True + ) + if replace: + for path in problem_data: + if (file := target / path.name).is_file(): + file.unlink() + elif (dir := target / path.name).is_dir(): + rmtree(dir) + else: + new_problem = False + + if new_problem: + with Status("Installing problem"): + res = run_process( + config.general.install_command + [build_dir.absolute()], + shell=False, + capture_output=True, + text=True, + env=environ.copy(), + ) + if res.returncode: + print("Couldn't install the problem") + console.print(f"[red]{res.stderr}") + raise Abort + for path in problem_data: + path.rename(target / path.name) + else: + problem_name = "Unknown Problem" + problem_config = TOMLDocument() + if target is None: + target = Path() + parsed_config = BaseConfig() # ! paths in this arent properly relativized + + with Status("Initializing metadata"): + problem_config.add( + "teams", + table().add( + team_name, + table().add("generator", "./generator").add("solver", "./solver"), + ), + ) + if config.default_exec is not None: + problem_config.add(config.default_exec) + (target / "config.toml").write_text(dumps_toml(problem_config)) + res_path = parsed_config.execution.results + if not res_path.is_absolute(): + res_path = (target / res_path).resolve() + res_path.mkdir(parents=True, exist_ok=True) + template_args: PartialTemplateArgs = { - "problem": "Blep", - "team": config.team_name or choice(("Dogs", "Cats", "Otters", "Red Pandas", "Possums", "Rats")), + "problem": problem_name, + "team": team_name, } if generator is not None: - _init_program(target, generator, template_args, Role.generator) + with Status("Initializing generator"): + _init_program(target, generator, template_args, Role.generator) if solver is not None: - _init_program(target, solver, template_args, Role.solver) + with Status("Initializing solver"): + _init_program(target, solver, template_args, Role.solver) + print(f"Initialized problem directory at {target}") @app.command() diff --git a/algobattle/match.py b/algobattle/match.py index d00eea35..2cb0bf5b 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -52,7 +52,7 @@ def from_file(cls, source: Path) -> Self: except tomllib.TOMLDecodeError as e: raise ValueError(f"The config file at {source} is not a properly formatted TOML file!\n{e}") Battle.load_entrypoints() - return cls.model_validate(config_dict, context={"base_path": source.parent}) + return cls.model_validate(config_dict, context={"base_path": source.parent, "check_problem": True}) class Match(BaseModel): diff --git a/algobattle/problem.py b/algobattle/problem.py index 9bcd6254..f5cf904c 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -21,7 +21,7 @@ ) from math import inf, isnan -from pydantic import AfterValidator, Field +from pydantic import AfterValidator, Field, ValidationInfo from algobattle.util import ( EncodableModel, @@ -33,8 +33,8 @@ ) -def _check_problem_name(val: str) -> str: - if val not in Problem.all(): +def _check_problem_name(val: str, info: ValidationInfo) -> str: + if info.context and info.context.get("check_problem", False) and val not in Problem.all(): raise ValueError("Value is not the name of an installed Problem.") return val diff --git a/algobattle/util.py b/algobattle/util.py index 805e9f5c..354c03fc 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -521,10 +521,12 @@ class ExecutionConfig(BaseModel): """Number of battles exectuted in parallel.""" mode: MatchMode = "testing" """Mode of the match.""" - set_cpus: str | list[str] | None = None + set_cpus: WithNone[str | list[str]] = None """Wich cpus to run programs on, if a list is specified each battle will use a different cpu specification in it.""" points: int = 100 """Highest number of points each team can achieve.""" + results: RelativePath = Path("./results") + """Path to a folder where the results will be saved.""" @model_validator(mode="after") def val_set_cpus(self) -> Self: From c0c5cf05fa8bc82682317e57499682d4ff026331 Mon Sep 17 00:00:00 2001 From: Imogen Date: Wed, 13 Sep 2023 04:11:46 +0200 Subject: [PATCH 013/113] update run command --- algobattle/cli.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 3fd4ba30..afd764b1 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -100,18 +100,7 @@ def default_exec(self) -> TomlTable | None: def run_match( path: Annotated[Path, Argument(exists=True, help="Path to either a config file or a directory containing one.")], ui: Annotated[bool, Option(help="Whether to show the CLI UI during match execution.")] = True, - result_path: Annotated[ - Optional[Path], # typer doesn't support union syntax - Option( - "--result", - "-r", - exists=True, - dir_okay=True, - file_okay=False, - writable=True, - help="If set, the match result object will be saved in the folder.", - ), - ] = None, + save: Annotated[bool, Option(help="Whether to save the match result.")] = True, ) -> Match: """Runs a match using the config found at the provided path and displays it to the cli.""" config = BaseConfig.from_file(path) @@ -130,10 +119,10 @@ def run_match( for team, pts in points.items(): print(f"Team {team} gained {pts:.1f} points.") - if result_path is not None: + if save: t = datetime.now() filename = f"{t.year:04d}-{t.month:02d}-{t.day:02d}_{t.hour:02d}-{t.minute:02d}-{t.second:02d}.json" - with open(result_path / filename, "w+") as f: + with open(config.execution.results / filename, "w+") as f: f.write(result.model_dump_json(exclude_defaults=True)) return result except KeyboardInterrupt: From b1a35969c6eae40f939a01793ee6c9730fb1f444 Mon Sep 17 00:00:00 2001 From: Imogen Date: Wed, 13 Sep 2023 04:19:46 +0200 Subject: [PATCH 014/113] remove dynamic problem imports --- algobattle/cli.py | 7 +-- algobattle/match.py | 3 +- algobattle/problem.py | 99 +++---------------------------------------- algobattle/team.py | 6 +-- algobattle/util.py | 4 +- tests/test_match.py | 2 +- tests/test_util.py | 23 ---------- 7 files changed, 18 insertions(+), 126 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index afd764b1..7408886a 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -2,22 +2,20 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ -from dataclasses import dataclass from datetime import datetime from os import environ from pathlib import Path from random import choice from shutil import rmtree -from subprocess import PIPE, run as run_process +from subprocess import run as run_process import sys from typing import Annotated, Any, ClassVar, Iterable, Literal, Optional, Self, cast from typing_extensions import override from importlib.metadata import version as pkg_version -from textwrap import dedent from zipfile import ZipFile from anyio import run as run_async_fn -from pydantic import Field, ValidationError, BaseModel +from pydantic import Field, BaseModel from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch, confirm from rich.console import Group, RenderableType, Console from rich.live import Live @@ -39,7 +37,6 @@ from rich.status import Status from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table from tomlkit.items import Table as TomlTable -from tomlkit.container import Container as TomlContainer from algobattle.battle import Battle from algobattle.match import BaseConfig, EmptyUi, Match, Ui diff --git a/algobattle/match.py b/algobattle/match.py index 2cb0bf5b..7a799ee4 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -15,10 +15,11 @@ from algobattle.battle import Battle, FightHandler, FightUi, BattleUi from algobattle.program import DockerConfig, ProgramUi from algobattle.team import BuildUi, Matchup, Team, TeamHandler, TeamInfos -from algobattle.problem import InstanceT, MatchConfig, Problem, SolutionT +from algobattle.problem import InstanceT, Problem, SolutionT from algobattle.util import ( ExceptionInfo, ExecutionConfig, + MatchConfig, Role, RunningTimer, BaseModel, diff --git a/algobattle/problem.py b/algobattle/problem.py index f5cf904c..83e08114 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -2,12 +2,9 @@ from abc import ABC, abstractmethod from functools import wraps from importlib.metadata import entry_points -import importlib.util -import sys from pathlib import Path from typing import ( TYPE_CHECKING, - Annotated, Any, Callable, ClassVar, @@ -21,27 +18,14 @@ ) from math import inf, isnan -from pydantic import AfterValidator, Field, ValidationInfo - from algobattle.util import ( EncodableModel, InstanceSolutionModel, - MatchConfigBase, - RelativeFilePath, Role, Encodable, ) -def _check_problem_name(val: str, info: ValidationInfo) -> str: - if info.context and info.context.get("check_problem", False) and val not in Problem.all(): - raise ValueError("Value is not the name of an installed Problem.") - return val - - -ProblemName = Annotated[str, AfterValidator(_check_problem_name)] - - class Instance(Encodable, ABC): """Instance base class.""" @@ -229,7 +213,6 @@ def __init__( # noqa: D107 solution_cls: type[SolutionT], min_size: int = 0, with_solution: Literal[True] = True, - export: bool = True, score_function: ScoreFunctionWithSol[InstanceT, SolutionT] = default_score, ) -> None: ... @@ -243,7 +226,6 @@ def __init__( # noqa: D107 solution_cls: type[SolutionT], min_size: int = 0, with_solution: Literal[False], - export: bool = True, score_function: ScoreFunctionNoSol[InstanceT, SolutionT] = default_score, ) -> None: ... @@ -256,7 +238,6 @@ def __init__( solution_cls: type[SolutionT], min_size: int = 0, with_solution: bool = True, - export: bool = True, score_function: ScoreFunction[InstanceT, SolutionT] = default_score, ) -> None: """The definition of a problem. @@ -267,9 +248,6 @@ def __init__( solution_cls: Class definitng what solutions of this problem look like. min_size: Minimum size of valid instances of this problem. with_solution: Whether the generator should also create a solution. - export: Wether the class should be exported. - If a battle is run by specifying a module, exactly one Problem in it must have `export=True`. It will - then be used to run the battle. score_function: Function used to score how well a solution solves a problem instance. The default scoring function returns the quotient of the solver's to the generator's solution score. @@ -284,12 +262,9 @@ def __init__( self.solution_cls = solution_cls self.min_size = min_size self.with_solution = with_solution - self.export = export self.score_function = score_function - if self.export and self.name not in self._installed: - self._installed[self.name] = self - __slots__ = ("name", "instance_cls", "solution_cls", "min_size", "with_solution", "export", "score_function") + __slots__ = ("name", "instance_cls", "solution_cls", "min_size", "with_solution", "score_function") _installed: "ClassVar[dict[str, AnyProblem]]" = {} @overload @@ -323,62 +298,12 @@ def score( return self.score_function(instance, solution=solution) @classmethod - def import_from_path(cls, path: Path) -> "AnyProblem": - """Try to import a Problem from a given path. - - The specified file will be imported using the standard python loaders. If the created module contains exactly - one Problem with the `export` flag set, it will be imported. - - Args: - path: A path to a module, or a folder containing an `__init__.py` or `problem.py` file. - - Raises: - ValueError: If the path doesn't point to a module or the file cannot be imported properly. - """ - if path.is_file(): - pass - elif (path / "problem.py").is_file(): - path /= "problem.py" - else: - raise ValueError(f"'{path}' does not point to a python file or a proper parent folder of one.") - - try: - spec = importlib.util.spec_from_file_location("_problem", path) - assert spec is not None - assert spec.loader is not None - problem_module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = problem_module - spec.loader.exec_module(problem_module) - except Exception as e: - raise ValueError from e - - try: - problems = [obj for obj in vars(problem_module).values() if isinstance(obj, cls) and obj.export] - match len(problems): - case 0: - raise ValueError(f"'{path}' contains no Problems.") - case 1: - problem = problems[0] - case _: - raise ValueError( - f"'{path}' contains {len(problems)} different problems: {', '.join(p.name for p in problems)}." - ) - - return problem - - finally: - sys.modules.pop("_problem") - - @classmethod - def get(cls, problem: ProblemName | Path) -> "AnyProblem": - """Gets either an installed problem instance using its name or imports a problem file.""" - if isinstance(problem, Path): - return cls.import_from_path(problem) - else: - all = cls.all() - if problem not in all: - raise ValueError("Problem name is not valid.") - return all[problem] + def get(cls, name: str) -> "AnyProblem": + """Gets an installed problem instance using its name or entrypoint.""" + all = cls.all() + if name not in all: + raise ValueError("This problem is not installed.") + return all[name] @classmethod def all(cls) -> "dict[str, AnyProblem]": @@ -422,13 +347,3 @@ def decode(cls, source: Path, max_size: int, role: Role, instance: InstanceT | N if instance is not None: context["instance"] = instance return cls._decode(source, **context) - - -class MatchConfig(MatchConfigBase): - """Match config settings with problem setting.""" - - problem: ProblemName | RelativeFilePath = Field(default=Path("problem.py"), validate_default=True) - """The problem this match is over. - - Either the name of an installed problem, or the path to a problem file - """ diff --git a/algobattle/team.py b/algobattle/team.py index 44e5812c..51df81f3 100644 --- a/algobattle/team.py +++ b/algobattle/team.py @@ -8,7 +8,7 @@ from algobattle.program import DockerConfig, Generator, Solver from algobattle.problem import AnyProblem -from algobattle.util import ExceptionInfo, MatchConfigBase, MatchMode, RelativePath, Role +from algobattle.util import ExceptionInfo, MatchConfig, MatchMode, RelativePath, Role _team_names: set[str] = set() @@ -40,7 +40,7 @@ async def build( self, name: str, problem: AnyProblem, - match_config: MatchConfigBase, + match_config: MatchConfig, docker_config: DockerConfig, name_programs: bool, ui: BuildUi, @@ -173,7 +173,7 @@ async def build( infos: TeamInfos, problem: AnyProblem, mode: MatchMode, - match_config: MatchConfigBase, + match_config: MatchConfig, docker_config: DockerConfig, ui: BuildUi, ) -> Self: diff --git a/algobattle/util.py b/algobattle/util.py index 354c03fc..e84ff8f1 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -498,12 +498,14 @@ def reify( return RunSpecs(timeout=timeout, space=space, cpus=cpus, overriden=overriden) -class MatchConfigBase(BaseModel): +class MatchConfig(BaseModel): """Parameters determining the match execution. It will be parsed from the given config file and contains all settings that specify how the match is run. """ + problem: str + """The problem to be solved in this match.""" build_timeout: WithNone[TimeDeltaFloat] = 600 """Timeout for building each docker image.""" image_size: WithNone[ByteSizeInt] = None diff --git a/tests/test_match.py b/tests/test_match.py index 9f636478..aff820d8 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -217,7 +217,7 @@ def setUpClass(cls) -> None: def test_no_cfg_default(self): cfg = BaseConfig.from_file(self.problem_path) - self.assertEqual(cfg, BaseConfig(teams=self.teams, match=MatchConfig(problem=self.problem_path / "problem.py"))) + self.assertEqual(cfg, BaseConfig(teams=self.teams, match=MatchConfig(problem="Test Problem"))) def test_empty_cfg(self): with self.assertRaises(ValidationError): diff --git a/tests/test_util.py b/tests/test_util.py index 1e918380..45b47c00 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,35 +1,12 @@ """Tests for all util functions.""" import unittest -import random -from pathlib import Path -from algobattle.match import BaseConfig -from algobattle.problem import MatchConfig, Problem from algobattle.battle import Battle, Iterated, Averaged -from . import testsproblem class Utiltests(unittest.TestCase): """Tests for the util functions.""" - @classmethod - def setUpClass(cls) -> None: - """Set up a problem, default config, fight handler and get a file name not existing on the file system.""" - cls.config = BaseConfig(match=MatchConfig(problem="Test Problem")) - cls.problem_path = Path(testsproblem.__file__).parent / "problem.py" - cls.rand_file_name = str(random.randint(0, 2**80)) - while Path(cls.rand_file_name).exists(): - cls.rand_file_name = str(random.randint(0, 2**80)) - - def test_import_problem_from_path_existing_path(self): - """Importing works when importing a Problem from an existing path.""" - self.assertIsNotNone(Problem.import_from_path(self.problem_path)) - - def test_import_problem_from_path_nonexistant_path(self): - """An import fails if importing from a nonexistant path.""" - with self.assertRaises(ValueError): - Problem.import_from_path(Path(self.rand_file_name)) - def test_default_battle_types(self): """Initializing an existing battle type works as expected.""" self.assertEqual(Battle.all()["Iterated"], Iterated) From c8943f0c0937df5075a41f9190371688e92a8bbd Mon Sep 17 00:00:00 2001 From: Imogen Date: Wed, 13 Sep 2023 04:48:06 +0200 Subject: [PATCH 015/113] cleanup config parsing --- algobattle/cli.py | 12 +++++++----- algobattle/match.py | 21 ++++++++++----------- algobattle/team.py | 4 +++- algobattle/util.py | 8 ++------ tests/test_match.py | 28 +++++++++++++--------------- 5 files changed, 35 insertions(+), 38 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 7408886a..7f3a1220 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -39,10 +39,10 @@ from tomlkit.items import Table as TomlTable from algobattle.battle import Battle -from algobattle.match import BaseConfig, EmptyUi, Match, Ui +from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui from algobattle.problem import Problem from algobattle.team import Matchup -from algobattle.util import ExecutionConfig, Role, RunningTimer, BaseModel as AlgobattleBaseModel, TempDir +from algobattle.util import ExecutionConfig, MatchConfig, Role, RunningTimer, BaseModel as AlgobattleBaseModel, TempDir from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates @@ -100,7 +100,7 @@ def run_match( save: Annotated[bool, Option(help="Whether to save the match result.")] = True, ) -> Match: """Runs a match using the config found at the provided path and displays it to the cli.""" - config = BaseConfig.from_file(path) + config = AlgobattleConfig.from_file(path) problem = Problem.get(config.match.problem) result = Match() try: @@ -179,7 +179,8 @@ def init( problem_zip.extractall(build_dir) problem_config = parse_toml((build_dir / "config.toml").read_text()) - parsed_config = BaseConfig.model_validate(problem_config) # ! paths in this arent properly relativized + # ! paths in this config arent properly relativized + parsed_config = AlgobattleConfig.model_validate(problem_config) problem_name = parsed_config.match.problem assert isinstance(problem_name, str) if target is None: @@ -220,7 +221,8 @@ def init( problem_config = TOMLDocument() if target is None: target = Path() - parsed_config = BaseConfig() # ! paths in this arent properly relativized + # ! paths in this config arent properly relativized + parsed_config = AlgobattleConfig(match=MatchConfig(problem="Uknown Problem")) with Status("Initializing metadata"): problem_config.add( diff --git a/algobattle/match.py b/algobattle/match.py index 7a799ee4..ad487f46 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -12,7 +12,7 @@ from anyio import create_task_group, CapacityLimiter from anyio.to_thread import current_default_thread_limiter -from algobattle.battle import Battle, FightHandler, FightUi, BattleUi +from algobattle.battle import Battle, FightHandler, FightUi, BattleUi, Iterated from algobattle.program import DockerConfig, ProgramUi from algobattle.team import BuildUi, Matchup, Team, TeamHandler, TeamInfos from algobattle.problem import InstanceT, Problem, SolutionT @@ -27,15 +27,14 @@ ) -class BaseConfig(BaseModel): +class AlgobattleConfig(BaseModel): """Base that contains all config options and can be parsed from config files.""" - # funky defaults to force their validation with context info present - teams: TeamInfos = Field(default={"team_0": {"generator": Path("generator"), "solver": Path("solver")}}) - execution: ExecutionConfig = Field(default_factory=dict, validate_default=True) - match: MatchConfig = Field(default_factory=dict, validate_default=True) - battle: Battle.Config = Field(default={"type": "Iterated"}, validate_default=True) - docker: DockerConfig = Field(default_factory=dict, validate_default=True) + teams: TeamInfos = Field(default_factory=dict) + execution: ExecutionConfig = Field(default=ExecutionConfig, validate_default=True) # to force path relativization + match: MatchConfig + battle: Battle.Config = Iterated.Config() + docker: DockerConfig = DockerConfig() @classmethod def from_file(cls, source: Path) -> Self: @@ -53,7 +52,7 @@ def from_file(cls, source: Path) -> Self: except tomllib.TOMLDecodeError as e: raise ValueError(f"The config file at {source} is not a properly formatted TOML file!\n{e}") Battle.load_entrypoints() - return cls.model_validate(config_dict, context={"base_path": source.parent, "check_problem": True}) + return cls.model_validate(config_dict, context={"base_path": source.parent}) class Match(BaseModel): @@ -69,7 +68,7 @@ async def _run_battle( self, battle: Battle, matchup: Matchup, - config: BaseConfig, + config: AlgobattleConfig, problem: Problem[InstanceT, SolutionT], cpus: list[str | None], ui: "Ui", @@ -101,7 +100,7 @@ async def _run_battle( async def run( self, - config: BaseConfig, + config: AlgobattleConfig, problem: Problem[InstanceT, SolutionT], ui: "Ui | None" = None, ) -> None: diff --git a/algobattle/team.py b/algobattle/team.py index 51df81f3..d222db77 100644 --- a/algobattle/team.py +++ b/algobattle/team.py @@ -4,7 +4,7 @@ from itertools import combinations from typing import Annotated, Any, Iterable, Iterator, Protocol, Self, TypeAlias -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from algobattle.program import DockerConfig, Generator, Solver from algobattle.problem import AnyProblem @@ -36,6 +36,8 @@ class TeamInfo(BaseModel): generator: RelativePath solver: RelativePath + model_config = ConfigDict(revalidate_instances="always") + async def build( self, name: str, diff --git a/algobattle/util.py b/algobattle/util.py index e84ff8f1..7c086656 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -434,13 +434,7 @@ def _relativize_path(path: Path, info: ValidationInfo) -> Path: return path -def _relativize_file(path: Path, info: ValidationInfo) -> Path: - path = _relativize_path(path, info) - return PathType.validate_file(path, info) - - RelativePath = Annotated[Path, AfterValidator(_relativize_path), Field(validate_default=True)] -RelativeFilePath = Annotated[Path, AfterValidator(_relativize_file), Field(validate_default=True)] class RunConfigOverride(TypedDict, total=False): @@ -530,6 +524,8 @@ class ExecutionConfig(BaseModel): results: RelativePath = Path("./results") """Path to a folder where the results will be saved.""" + model_config = ConfigDict(revalidate_instances="always") + @model_validator(mode="after") def val_set_cpus(self) -> Self: """Validates that each battle that is being executed is assigned some cpu cores.""" diff --git a/tests/test_match.py b/tests/test_match.py index aff820d8..b2002b8c 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -7,7 +7,7 @@ from pydantic import ByteSize, ValidationError from algobattle.battle import Fight, Iterated, Averaged -from algobattle.match import BaseConfig, Match, MatchConfig +from algobattle.match import AlgobattleConfig, Match, MatchConfig from algobattle.team import Team, Matchup, TeamHandler, TeamInfo from algobattle.program import ProgramRunInfo, RunConfig from algobattle.util import ExecutionConfig @@ -155,11 +155,11 @@ def setUpClass(cls) -> None: problem_path = Path(__file__).parent / "testsproblem" cls.problem = TestProblem run_params = RunConfig(timeout=2) - cls.config_iter = BaseConfig( + cls.config_iter = AlgobattleConfig( match=MatchConfig(generator=run_params, solver=run_params, problem="Test Problem"), battle=Iterated.Config(maximum_size=10, rounds=2), ) - cls.config_avg = BaseConfig( + cls.config_avg = AlgobattleConfig( match=MatchConfig(generator=run_params, solver=run_params, problem="Test Problem"), battle=Averaged.Config(instance_size=5, num_fights=3), ) @@ -216,35 +216,32 @@ def setUpClass(cls) -> None: cls.teams = {"team_0": TeamInfo(generator=cls.problem_path / "generator", solver=cls.problem_path / "solver")} def test_no_cfg_default(self): - cfg = BaseConfig.from_file(self.problem_path) - self.assertEqual(cfg, BaseConfig(teams=self.teams, match=MatchConfig(problem="Test Problem"))) + with self.assertRaises(ValidationError): + AlgobattleConfig.from_file(self.problem_path) def test_empty_cfg(self): with self.assertRaises(ValidationError): - BaseConfig.from_file(self.configs_path / "empty.toml") + AlgobattleConfig.from_file(self.configs_path / "empty.toml") def test_cfg(self): - cfg = BaseConfig.from_file(self.configs_path / "test.toml") + cfg = AlgobattleConfig.from_file(self.configs_path / "test.toml") self.assertEqual( cfg, - BaseConfig( - teams={ - "team_0": TeamInfo(generator=self.configs_path / "generator", solver=self.configs_path / "solver") - }, + AlgobattleConfig( match=MatchConfig( generator=RunConfig(space=ByteSize(10)), problem="Test Problem", ), battle=Averaged.Config(num_fights=1), - execution=ExecutionConfig(points=10), + execution=ExecutionConfig(points=10, results=self.configs_path / "results"), ), ) def test_cfg_team(self): - cfg = BaseConfig.from_file(self.configs_path / "teams.toml") + cfg = AlgobattleConfig.from_file(self.configs_path / "teams.toml") self.assertEqual( cfg, - BaseConfig( + AlgobattleConfig( teams={ "team 1": TeamInfo(generator=self.configs_path, solver=self.configs_path), "team 2": TeamInfo(generator=self.configs_path, solver=self.configs_path), @@ -252,12 +249,13 @@ def test_cfg_team(self): match=MatchConfig( problem="Test Problem", ), + execution=ExecutionConfig(results=self.configs_path / "results"), ), ) def test_cfg_team_no_name(self): with self.assertRaises(ValueError): - BaseConfig.from_file(self.configs_path / "teams_incorrect.toml") + AlgobattleConfig.from_file(self.configs_path / "teams_incorrect.toml") if __name__ == "__main__": From cfd8303541a2758fe41e559101e4610995eb19a2 Mon Sep 17 00:00:00 2001 From: Imogen Date: Wed, 13 Sep 2023 05:01:31 +0200 Subject: [PATCH 016/113] simplify Match.run args --- algobattle/cli.py | 11 ++++++----- algobattle/match.py | 10 +++++----- algobattle/problem.py | 8 -------- algobattle/util.py | 1 - tests/test_match.py | 9 +++------ 5 files changed, 14 insertions(+), 25 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 7f3a1220..ef4cddc4 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -101,11 +101,13 @@ def run_match( ) -> Match: """Runs a match using the config found at the provided path and displays it to the cli.""" config = AlgobattleConfig.from_file(path) - problem = Problem.get(config.match.problem) + if config.match.problem not in Problem.all(): + print("The problem specified in the config file ({config.match.problem}) is not installed") + raise Abort result = Match() try: with CliUi() if ui else EmptyUi() as ui_obj: - run_async_fn(result.run, config, problem, ui_obj) + run_async_fn(result.run, config, ui_obj) except KeyboardInterrupt: console.print("Received keyboard interrupt, terminating execution.") finally: @@ -384,8 +386,9 @@ def _fights_table(self) -> Table: class CliUi(Live, Ui): """Ui that uses rich to draw to the console.""" + match: Match + def __init__(self) -> None: - self.match = None self.battle_panels: dict[Matchup, BattlePanel] = {} super().__init__(None, refresh_per_second=10, transient=True) @@ -394,7 +397,6 @@ def __enter__(self) -> Self: def _update_renderable(self, renderable: RenderableType | None = None) -> None: if renderable is None: - assert self.match is not None renderable = Group(self.display_match(self.match), *self.battle_panels.values()) self.update(Panel(renderable, title=f"[orange1]Algobattle {pkg_version('algobattle_base')}")) @@ -460,7 +462,6 @@ def start_fight(self, matchup: Matchup, max_size: int) -> None: @override def end_fight(self, matchup: Matchup) -> None: - assert self.match is not None battle = self.match.battle(matchup) assert battle is not None fights = battle.fights[-1:-6:-1] diff --git a/algobattle/match.py b/algobattle/match.py index ad487f46..7e25df32 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -101,9 +101,8 @@ async def _run_battle( async def run( self, config: AlgobattleConfig, - problem: Problem[InstanceT, SolutionT], ui: "Ui | None" = None, - ) -> None: + ) -> Self: """Runs a match with the given config settings and problem type. The first step is building the docker images for each team in `config.teams`. Any teams where this process fails @@ -116,6 +115,7 @@ async def run( if ui is None: ui = EmptyUi() ui.match = self + problem = Problem.all()[config.match.problem] with await TeamHandler.build( config.teams, problem, config.execution.mode, config.match, config.docker, ui @@ -136,6 +136,7 @@ async def run( battle = battle_cls() self.results[matchup.generator.name][matchup.solver.name] = 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: @@ -242,7 +243,7 @@ class Ui(BuildUi, Protocol): by just subclassing :class:`Ui` and implementing its methods. """ - match: Match | None + match: Match def start_build_step(self, teams: Iterable[str], timeout: float | None) -> None: """Tells the ui that the build process has started.""" @@ -294,11 +295,10 @@ def end_program(self, matchup: Matchup, role: Role, runtime: float) -> None: return -@dataclass class EmptyUi(Ui): """A dummy Ui.""" - match: Match | None = field(default=None, init=False) + match: Match def __enter__(self) -> Self: """Starts displaying the Ui.""" diff --git a/algobattle/problem.py b/algobattle/problem.py index 83e08114..81b79651 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -297,14 +297,6 @@ def score( assert isinstance(self.score_function, ScoreFunctionNoSol) return self.score_function(instance, solution=solution) - @classmethod - def get(cls, name: str) -> "AnyProblem": - """Gets an installed problem instance using its name or entrypoint.""" - all = cls.all() - if name not in all: - raise ValueError("This problem is not installed.") - return all[name] - @classmethod def all(cls) -> "dict[str, AnyProblem]": """Returns a dictionary mapping the names of all installed problems to their python objects. diff --git a/algobattle/util.py b/algobattle/util.py index 7c086656..7809a469 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -29,7 +29,6 @@ ValidationInfo, model_validator, ) -from pydantic.types import PathType from pydantic_core import CoreSchema from pydantic_core.core_schema import general_after_validator_function, union_schema, no_info_after_validator_function diff --git a/tests/test_match.py b/tests/test_match.py index b2002b8c..0175bb2b 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -168,8 +168,7 @@ def setUpClass(cls) -> None: async def test_basic(self): self.config_iter.teams = {"team_0": TeamInfo(generator=self.generator, solver=self.solver)} - res = Match() - await res.run(self.config_iter, TestProblem) + res = await Match().run(self.config_iter) for res_dict in res.results.values(): for result in res_dict.values(): self.assertIsNone(result.run_exception) @@ -182,8 +181,7 @@ async def test_multi_team(self): team0 = TeamInfo(generator=self.generator, solver=self.solver) team1 = TeamInfo(generator=self.generator, solver=self.solver) self.config_iter.teams = {"team_0": team0, "team_1": team1} - res = Match() - await res.run(self.config_iter, TestProblem) + res = await Match().run(self.config_iter) for res_dict in res.results.values(): for result in res_dict.values(): self.assertIsNone(result.run_exception) @@ -194,8 +192,7 @@ async def test_multi_team(self): async def test_averaged(self): self.config_avg.teams = {"team_0": TeamInfo(generator=self.generator, solver=self.solver)} - res = Match() - await res.run(self.config_avg, TestProblem) + res = await Match().run(self.config_avg) for res_dict in res.results.values(): for result in res_dict.values(): self.assertIsNone(result.run_exception) From 0faad786f514638f5cc240d4046dd85e9b2cc305 Mon Sep 17 00:00:00 2001 From: Imogen Date: Wed, 13 Sep 2023 15:13:54 +0200 Subject: [PATCH 017/113] prompt user for install type --- algobattle/cli.py | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index ef4cddc4..1dd351b8 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -3,6 +3,7 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ from datetime import datetime +from enum import StrEnum from os import environ from pathlib import Path from random import choice @@ -15,7 +16,7 @@ from zipfile import ZipFile from anyio import run as run_async_fn -from pydantic import Field, BaseModel +from pydantic import Field from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch, confirm from rich.console import Group, RenderableType, Console from rich.live import Live @@ -35,6 +36,7 @@ from rich.text import Text from rich.columns import Columns from rich.status import Status +from rich.prompt import Prompt from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table from tomlkit.items import Table as TomlTable @@ -42,7 +44,7 @@ from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui from algobattle.problem import Problem from algobattle.team import Matchup -from algobattle.util import ExecutionConfig, MatchConfig, Role, RunningTimer, BaseModel as AlgobattleBaseModel, TempDir +from algobattle.util import ExecutionConfig, MatchConfig, Role, RunningTimer, BaseModel, TempDir from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates @@ -53,18 +55,22 @@ console = Console() +class _InstallMode(StrEnum): + normal = "normal" + user = "user" + + class _General(BaseModel): team_name: str | None = None - install_command: list[str] = [sys.executable, "-m", "pip", "install"] + install_mode: _InstallMode | None = None -class CliConfig(BaseModel, frozen=True): +class CliConfig(BaseModel): general: _General = Field(default_factory=dict, validate_default=True) execution: ExecutionConfig = Field(default_factory=dict, validate_default=True) _doc: TOMLDocument path: ClassVar[Path] = Path(get_app_dir("algobattle")) / "config.toml" - model_config = AlgobattleBaseModel.model_config @classmethod def init_file(cls) -> None: @@ -92,6 +98,27 @@ def default_exec(self) -> TomlTable | None: exec: Any = self._doc.get("exec", None) return exec + def install_cmd(self, target: Path) -> list[str]: + cmd = [sys.executable, "-m", "pip", "install"] + if self.general.install_mode is None: + command_str: str = Prompt.ask( + "[cyan]Do you want to install problems normally, or into the user directory?[/] If you're using an " + "environment manager like venv or conda you should install them normally, otherwise user installs " + "might be better.", + default="normal", + choices=["normal", "user"], + ) + if command_str == "user": + cmd.append("--user") + self.general.install_mode = _InstallMode.user + else: + self.general.install_mode = _InstallMode.normal + if "general" not in self._doc: + self._doc.add("general", table()) + cast(TomlTable, self._doc["general"])["install_mode"] = command_str + self.save() + return cmd + [str(target.resolve())] + @app.command("run") def run_match( @@ -145,7 +172,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: @app.command() def init( target: Annotated[ - Optional[Path], Argument(exists=True, file_okay=False, writable=True, help="The folder to initialize.") + Optional[Path], Argument(file_okay=False, writable=True, help="The folder to initialize.") ] = None, problem: Annotated[ Optional[Path], @@ -187,6 +214,7 @@ def init( assert isinstance(problem_name, str) if target is None: target = Path() if Path().name == problem_name else Path() / problem_name + target.mkdir(parents=True, exist_ok=True) new_problem = True problem_data = list(build_dir.iterdir()) @@ -204,9 +232,10 @@ def init( new_problem = False if new_problem: + cmd = config.install_cmd(build_dir) with Status("Installing problem"): res = run_process( - config.general.install_command + [build_dir.absolute()], + cmd, shell=False, capture_output=True, text=True, From cde86470226590f9bbe7211d84e68e8a4fa056e7 Mon Sep 17 00:00:00 2001 From: Imogen Date: Wed, 13 Sep 2023 15:26:47 +0200 Subject: [PATCH 018/113] improve prompt styling --- algobattle/cli.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 1dd351b8..6b019225 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -17,7 +17,7 @@ from anyio import run as run_async_fn from pydantic import Field -from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch, confirm +from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch from rich.console import Group, RenderableType, Console from rich.live import Live from rich.table import Table, Column @@ -36,7 +36,7 @@ from rich.text import Text from rich.columns import Columns from rich.status import Status -from rich.prompt import Prompt +from rich.prompt import Prompt, Confirm from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table from tomlkit.items import Table as TomlTable @@ -158,7 +158,9 @@ def run_match( def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: Role) -> None: dir = target / role.value if dir.exists(): - replace = confirm(f"The targeted directory already contains a {role}, do you want to replace it?", default=True) + replace = Confirm.ask( + f"[magenta2]The targeted directory already contains a {role}, do you want to replace it?", default=True + ) if replace: rmtree(dir) dir.mkdir() @@ -166,7 +168,8 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: return else: dir.mkdir(parents=True, exist_ok=True) - write_templates(dir, lang, cast(TemplateArgs, args | {"program": role.value})) + with Status(f"Initializing {role}"): + write_templates(dir, lang, cast(TemplateArgs, args | {"program": role.value})) @app.command() @@ -219,8 +222,9 @@ def init( new_problem = True problem_data = list(build_dir.iterdir()) if any(((target / path.name).exists() for path in problem_data)): - replace = confirm( - "The target directory already contains problem data, do you want to replace it?", default=True + replace = Confirm.ask( + "[magenta2]The target directory already contains problem data, do you want to replace it?", + default=True, ) if replace: for path in problem_data: @@ -275,14 +279,11 @@ def init( "problem": problem_name, "team": team_name, } - if generator is not None: - with Status("Initializing generator"): - _init_program(target, generator, template_args, Role.generator) - + _init_program(target, generator, template_args, Role.generator) if solver is not None: - with Status("Initializing solver"): - _init_program(target, solver, template_args, Role.solver) + _init_program(target, solver, template_args, Role.solver) + print(f"Initialized problem directory at {target}") From 496f255397ca3976399b72f980c9fb6c2e09aba4 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 14 Sep 2023 04:42:03 +0200 Subject: [PATCH 019/113] add test commands --- algobattle/cli.py | 78 +++++++++++++++++++++++++++++++++++++++++-- algobattle/match.py | 5 +++ algobattle/problem.py | 27 ++++----------- algobattle/program.py | 18 ++++++++++ 4 files changed, 105 insertions(+), 23 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 215e625c..ed24f207 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -42,8 +42,9 @@ from algobattle.battle import Battle from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig, MatchConfig -from algobattle.program import Matchup -from algobattle.util import Role, RunningTimer, BaseModel, TempDir +from algobattle.problem import Instance +from algobattle.program import Generator, Matchup, Solver +from algobattle.util import ExceptionInfo, Role, RunningTimer, BaseModel, TempDir from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates @@ -283,6 +284,79 @@ def init( print(f"Initialized problem directory at {target}") +@app.command() +def test( + folder: Annotated[Path, Argument(file_okay=False, writable=True, help="The problem folder to use.")] = Path(), + generator: Annotated[bool, Option(help="Whether to test the generator")] = True, + solver: Annotated[bool, Option(help="Whether to test the solver")] = True, + team: Annotated[Optional[str], Option(help="Name of the team whose programs you want to test.")] = None, +) -> None: + """Tests whether the programs install successfully and run on dummy instances without crashing.""" + config = AlgobattleConfig.from_file(folder) + problem = config.problem + if problem is None: + print(f"The problem specified in the config file ({config.match.problem}) is not installed.") + raise Abort + if team: + try: + team_obj = config.teams[team] + except KeyError: + console.print("[red]The specified team does not exist in the config file.") + raise Abort + else: + match len(config.teams): + case 0: + console.print("[red]The config file contains no teams.") + raise Abort + case 1: + team_obj = next(iter(config.teams.values())) + case _: + console.print("[red]The config file contains more than one team and none were specified.") + raise Abort + + gen_instance = None + if generator: + async def gen_builder() -> Generator: + with Status("Building generator"): + return await Generator.build(team_obj.generator, problem=problem, config=config.as_prog_config()) + + with run_async_fn(gen_builder) as gen: + with Status("Running generator"): + gen_instance = gen.test() + if isinstance(gen_instance, ExceptionInfo): + console.print("[red]The generator didn't run successfully.") + config.execution.results.write_text(gen_instance.model_dump_json()) + gen_instance = None + + sol_error = None + if solver: + if gen_instance is None: + if problem.test_instance is None: + console.print( + "[magenta2]Cannot test the solver since the generator failed and the problem doesn't provide a test" + " instance." + ) + raise Exit + else: + instance = cast(Instance, problem.test_instance) + else: + instance = gen_instance + + async def sol_builder() -> Solver: + with Status("Building solver"): + return await Solver.build(team_obj.generator, problem=problem, config=config.as_prog_config()) + + with run_async_fn(sol_builder) as sol: + with Status("Running solver"): + sol_error = sol.test(instance) + if isinstance(sol_error, ExceptionInfo): + console.print("[red]The solver didn't run successfully.") + config.execution.results.write_text(sol_error.model_dump_json()) + + if gen_instance is not None and sol_error is None: + console.print("[green]Both programs tested successfully.") + + @app.command() def config() -> None: """Opens the algobattle cli tool config file.""" diff --git a/algobattle/match.py b/algobattle/match.py index df960986..8c466167 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -626,6 +626,11 @@ class AlgobattleConfig(BaseModel): model_config = ConfigDict(revalidate_instances="always") + @cached_property + def problem(self) -> Problem[Any, Any] | None: + """The problem this config uses.""" + return Problem.load(self.match.problem) + @classmethod def from_file(cls, file: Path) -> Self: """Parses a config object from a toml file. diff --git a/algobattle/problem.py b/algobattle/problem.py index 8c6543aa..76339322 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -217,6 +217,7 @@ def __init__( # noqa: D107 min_size: int = 0, with_solution: Literal[True] = True, score_function: ScoreFunctionWithSol[InstanceT, SolutionT] = default_score, + test_instance: InstanceT | None = None, ) -> None: ... @@ -230,6 +231,7 @@ def __init__( # noqa: D107 min_size: int = 0, with_solution: Literal[False], score_function: ScoreFunctionNoSol[InstanceT, SolutionT] = default_score, + test_instance: InstanceT | None = None, ) -> None: ... @@ -242,6 +244,7 @@ def __init__( min_size: int = 0, with_solution: bool = True, score_function: ScoreFunction[InstanceT, SolutionT] = default_score, + test_instance: InstanceT | None = None, ) -> None: """The definition of a problem. @@ -259,6 +262,7 @@ def __init__( gets the generated solutions at `generator_solution` and `solver_solution`. If it is not set it receives the solver's solution at `solution`. It should return the calculated score, a number in [0, 1] with a value of 0 indicating that the solver failed completely and 1 that it solved the instance perfectly. + test_instance: A dummy instance that can be used to test whether a solver produces correct output. """ self.name = name self.instance_cls = instance_cls @@ -266,8 +270,9 @@ def __init__( self.min_size = min_size self.with_solution = with_solution self.score_function = score_function + self.test_instance = test_instance - __slots__ = ("name", "instance_cls", "solution_cls", "min_size", "with_solution", "score_function") + __slots__ = ("name", "instance_cls", "solution_cls", "min_size", "with_solution", "score_function", "test_instance") _installed: "ClassVar[dict[str, AnyProblem]]" = {} @overload @@ -300,26 +305,6 @@ def score( assert isinstance(self.score_function, ScoreFunctionNoSol) return self.score_function(instance, solution=solution) - @classmethod - def all(cls) -> "dict[str, AnyProblem]": - """Returns a dictionary mapping the names of all installed problems to their python objects. - - It includes all Problem objects that have been created so far and ones exposed to the algobattle module via the - `algobattle.problem` entrypoint hook. - - Raises: - RuntimeError: If an entrypoint is not a Problem. - """ - for name, entrypoint in problem_entrypoints().items(): - if name not in cls._installed: - problem = entrypoint.load() - if not isinstance(problem, cls): - raise RuntimeError( - f"The entrypoint '{entrypoint.name}' doesn't point to a problem but rather: {problem}." - ) - cls._installed[entrypoint.name] = problem - return cls._installed - @classmethod def load(cls, problem: "ProblemName") -> "AnyProblem": """Gets either an installed problem instance using its name or imports an entrypoint.""" diff --git a/algobattle/program.py b/algobattle/program.py index 7e906bfc..38077882 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -22,6 +22,7 @@ from docker.types import Mount from requests import Timeout, ConnectionError from pydantic import Field +from anyio import run as run_async from anyio.to_thread import run_sync from urllib3.exceptions import ReadTimeoutError @@ -577,6 +578,15 @@ async def run( solution=solution, ) + def test(self) -> Instance | ExceptionInfo: + """Tests whether the generator runs without issues and creates a syntactically valid instance.""" + res = run_async(self.run, self.problem.min_size) + if res.info.error: + return res.info.error + else: + assert res.instance is not None + return res.instance + class Solver(Program): """A higher level interface for a team's solver.""" @@ -672,6 +682,14 @@ async def run( solution=solution, ) + def test(self, instance: Instance) -> ExceptionInfo | None: + """Tests whether the solver runs without issues and creates a syntactically valid solution.""" + res = run_async(self.run, instance, instance.size) + if res.info.error: + return res.info.error + else: + return None + class BuildUi(Protocol): """Provides and interface for the build process to update the ui.""" From 86f7ff5c74e4ade53f37ff7bc919da9159b263d9 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 14 Sep 2023 06:36:54 +0200 Subject: [PATCH 020/113] improve init command formatting --- algobattle/cli.py | 44 ++++++++++++------- algobattle/templates/__init__.py | 15 +++++-- algobattle/templates/python/Dockerfile | 9 ---- algobattle/templates/python/Dockerfile.jinja | 8 ++++ .../{pyproject.toml => pyproject.toml.jinja} | 0 .../templates/python/{{program}}.py.jinja | 41 +++++++++++++++++ .../templates/python/{{program}}/main.py | 24 ---------- 7 files changed, 88 insertions(+), 53 deletions(-) delete mode 100644 algobattle/templates/python/Dockerfile create mode 100644 algobattle/templates/python/Dockerfile.jinja rename algobattle/templates/python/{pyproject.toml => pyproject.toml.jinja} (100%) create mode 100644 algobattle/templates/python/{{program}}.py.jinja delete mode 100644 algobattle/templates/python/{{program}}/main.py diff --git a/algobattle/cli.py b/algobattle/cli.py index ed24f207..6cc6326b 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -42,9 +42,9 @@ from algobattle.battle import Battle from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig, MatchConfig -from algobattle.problem import Instance +from algobattle.problem import Instance, Problem from algobattle.program import Generator, Matchup, Solver -from algobattle.util import ExceptionInfo, Role, RunningTimer, BaseModel, TempDir +from algobattle.util import EncodableModel, ExceptionInfo, Role, RunningTimer, BaseModel, TempDir from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates @@ -156,7 +156,8 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: dir = target / role.value if dir.exists(): replace = Confirm.ask( - f"[magenta2]The targeted directory already contains a {role}, do you want to replace it?", default=True + f"[magenta2]The targeted directory already contains a {role.value}, do you want to replace it?", + default=True, ) if replace: rmtree(dir) @@ -166,7 +167,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: else: dir.mkdir(parents=True, exist_ok=True) with Status(f"Initializing {role}"): - write_templates(dir, lang, cast(TemplateArgs, args | {"program": role.value})) + write_templates(dir, lang, TemplateArgs(program=role.value, **args)) @app.command() @@ -249,39 +250,47 @@ def init( for path in problem_data: path.rename(target / path.name) else: - problem_name = "Unknown Problem" - problem_config = TOMLDocument() if target is None: target = Path() + if not target.joinpath("config.toml").is_file(): + console.print("[red]You must either use a problem spec file or target a directory with an existing config.") + raise Abort + problem_config = parse_toml(target.joinpath("config.toml").read_text()) # ! paths in this config arent properly relativized - parsed_config = AlgobattleConfig(match=MatchConfig(problem="Uknown Problem")) + parsed_config = AlgobattleConfig.model_validate(problem_config) + problem_name = parsed_config.match.problem with Status("Initializing metadata"): - problem_config.add( - "teams", - table().add( - team_name, - table().add("generator", "./generator").add("solver", "./solver"), - ), - ) - if config.default_exec is not None: - problem_config.add(config.default_exec) + if "teams" not in problem_config: + problem_config.add( + "teams", + table().add( + team_name, + table().add("generator", "./generator").add("solver", "./solver"), + ), + ) + if config.default_exec is not None and "execution" not in problem_config: + problem_config["execution"] = config.default_exec (target / "config.toml").write_text(dumps_toml(problem_config)) res_path = parsed_config.execution.results if not res_path.is_absolute(): res_path = (target / res_path).resolve() res_path.mkdir(parents=True, exist_ok=True) + problem_obj = Problem.load(problem_name) template_args: PartialTemplateArgs = { "problem": problem_name, "team": team_name, + "with_solution": problem_obj.with_solution, + "instance_json": issubclass(problem_obj.instance_cls, EncodableModel), + "solution_json": issubclass(problem_obj.solution_cls, EncodableModel), } if generator is not None: _init_program(target, generator, template_args, Role.generator) if solver is not None: _init_program(target, solver, template_args, Role.solver) - print(f"Initialized problem directory at {target}") + console.print(f"[green]Success![/] initialized algobattle project data in [cyan]{target}[/]") @app.command() @@ -316,6 +325,7 @@ def test( gen_instance = None if generator: + async def gen_builder() -> Generator: with Status("Building generator"): return await Generator.build(team_obj.generator, problem=problem, config=config.as_prog_config()) diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 93450653..fe2bcbbe 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -15,7 +15,10 @@ class Language(StrEnum): ENVS = { "python": Environment( - loader=PackageLoader("algobattle.templates", "python"), keep_trailing_newline=True, line_statement_prefix="# ?" + loader=PackageLoader("algobattle.templates", "python"), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, ) } @@ -25,6 +28,9 @@ class PartialTemplateArgs(TypedDict): problem: str team: str + with_solution: bool + instance_json: bool + solution_json: bool class TemplateArgs(PartialTemplateArgs): @@ -39,7 +45,7 @@ def normalize(s: str) -> str: def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: - """Yields all templates and where they should be placed.""" + """Writes the formatted templates to the target directory.""" template_args = args | { "project": f"{normalize(args['team'])}-{normalize(args['problem'])}-{normalize(args['program'])}", } @@ -47,7 +53,10 @@ def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: for name in env.list_templates(): template = env.get_template(name) formatted = template.render(template_args) - formatted_path = Template(name).render(template_args) + formatted_path = Path(Template(name).render(template_args)) + if formatted_path.suffix == ".jinja": + formatted_path = formatted_path.with_suffix("") + (target / formatted_path).parent.mkdir(parents=True, exist_ok=True) with open(target / formatted_path, "w+") as file: file.write(formatted) diff --git a/algobattle/templates/python/Dockerfile b/algobattle/templates/python/Dockerfile deleted file mode 100644 index cca1e0b3..00000000 --- a/algobattle/templates/python/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM python:3.11 - -WORKDIR /algobattle_src -COPY pyproject.toml . -COPY {{ program }} {{ program }} -RUN pip install . - -WORKDIR / -CMD [ "python", "-m", "{{ program }}.main" ] diff --git a/algobattle/templates/python/Dockerfile.jinja b/algobattle/templates/python/Dockerfile.jinja new file mode 100644 index 00000000..1b782180 --- /dev/null +++ b/algobattle/templates/python/Dockerfile.jinja @@ -0,0 +1,8 @@ +FROM python:3.11 + +WORKDIR /algobattle_src +COPY . . +RUN pip install . + +WORKDIR / +CMD [ "python", "-m", "{{ program }}" ] diff --git a/algobattle/templates/python/pyproject.toml b/algobattle/templates/python/pyproject.toml.jinja similarity index 100% rename from algobattle/templates/python/pyproject.toml rename to algobattle/templates/python/pyproject.toml.jinja diff --git a/algobattle/templates/python/{{program}}.py.jinja b/algobattle/templates/python/{{program}}.py.jinja new file mode 100644 index 00000000..214915ea --- /dev/null +++ b/algobattle/templates/python/{{program}}.py.jinja @@ -0,0 +1,41 @@ +"""Main module, will be run as the program.""" +{% if instance_json or solution_json %} +import json +{% endif %} +from pathlib import Path + + +{% if program == "generator" %} +max_size = int(Path("/input/max_size.txt").read_text()) + + +instance = ... +{% if with_solution %} +solution = ... +{% endif %} + + +{% if instance_json %} +Path("/output/instance.json").write_text(json.dumps(instance)) +{% else %} +ouput_path = Path("/output/instance") # this is where you need to write the instance to +{% endif %} +{% else %} +{% if instance_json %} +instance = json.loads(Path("/input/instance.json").read_text()) +{% else %} +instance_path = Path("/input/instance") # this is where you can read the instance from +{% endif %} + + +solution = ... + + +{% endif %} +{% if program == "solver" or with_solution %} +{% if solution_json %} +Path("/output/solution.json").write_text(json.dumps(solution)) +{% else %} +ouput_path = Path("/output/solution") # this is where you need to write the solution to +{% endif %} +{% endif %} diff --git a/algobattle/templates/python/{{program}}/main.py b/algobattle/templates/python/{{program}}/main.py deleted file mode 100644 index 4d2ec56c..00000000 --- a/algobattle/templates/python/{{program}}/main.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Main module, will be run as the program.""" -import json - - -# ? if program == "generator" -with open("/input/max_size.txt") as file: - max_size = int(file.read()) - -# ! your code here -instance = {} -solution = {} - -with open("/output/instance.json", "w+") as file: - json.dump(instance, file) -# ? else -with open("/input/instance.json") as file: - instance = json.load(file) - -# ! your code here -solution = {} - -# ? endif -with open("/output/solution.json", "w+") as file: - json.dump(solution, file) From b744a9fc2bf7dbd70d31e142ecac1600770c4d2c Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 00:19:57 +0200 Subject: [PATCH 021/113] ignore uninstalled problems/battle types when parsing for metadata --- algobattle/battle.py | 66 ++++++++++++++++++++++++++++++++++++++----- algobattle/cli.py | 10 ++----- algobattle/match.py | 37 ++++++++++++++++-------- algobattle/problem.py | 41 ++++++++++++++------------- algobattle/util.py | 10 ------- 5 files changed, 110 insertions(+), 54 deletions(-) diff --git a/algobattle/battle.py b/algobattle/battle.py index de8566e4..3968925f 100644 --- a/algobattle/battle.py +++ b/algobattle/battle.py @@ -9,6 +9,7 @@ from abc import abstractmethod from inspect import isclass from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, @@ -22,9 +23,16 @@ TypeVar, ) -from pydantic import Field, GetCoreSchemaHandler +from pydantic import ( + ConfigDict, + Field, + GetCoreSchemaHandler, + ValidationError, + ValidationInfo, + ValidatorFunctionWrapHandler, +) from pydantic_core import CoreSchema -from pydantic_core.core_schema import tagged_union_schema +from pydantic_core.core_schema import tagged_union_schema, union_schema, general_wrap_validator_function from algobattle.program import ( Generator, @@ -242,13 +250,14 @@ def __get_pydantic_core_schema__(cls, source: Type, handler: GetCoreSchemaHandle return handler(source) except NameError: return handler(source) + match len(Battle._battle_types): case 0: - return handler(source) + subclass_schema = handler(source) case 1: - return handler(next(iter(Battle._battle_types.values()))) + subclass_schema = handler(next(iter(Battle._battle_types.values()))) case _: - return tagged_union_schema( + subclass_schema = tagged_union_schema( choices={ battle.Config.model_fields["type"].default: battle.Config.__pydantic_core_schema__ for battle in Battle._battle_types.values() @@ -256,6 +265,48 @@ def __get_pydantic_core_schema__(cls, source: Type, handler: GetCoreSchemaHandle discriminator="type", ) + # we want to validate into the actual battle type's config, so we need to treat them as a tagged union + # but if we're initializing a project the type might not be installed yet, so we want to also parse + # into an unspecified dummy object. This wrap validator will efficiently and transparently act as a tagged + # union when ignore_uninstalled is not set. If it is set it catches only the error of a missing tag, other + # errors are passed through + def check_installed(val: object, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> object: + try: + return handler(val) + except ValidationError as e: + union_err = next(filter(lambda err: err["type"] == "union_tag_invalid", e.errors()), None) + if union_err is None: + raise + if info.context is not None and info.context.get("ignore_uninstalled", False): + if info.config is not None: + settings: dict[str, Any] = { + "strict": info.config.get("strict", None), + "from_attributes": info.config.get("from_attributes"), + } + else: + settings = {} + return Battle.FallbackConfig.model_validate(val, context=info.context, **settings) + else: + passed = union_err["input"]["type"] + installed = ", ".join(b.name() for b in Battle._battle_types.values()) + raise ValueError( + f"The specified battle type '{passed}' is not installed. Installed types are: {installed}" + ) + + return general_wrap_validator_function(check_installed, subclass_schema) + + class FallbackConfig(Config): + """Fallback config object to parse into if the proper battle typ isn't installed and we're ignoring installs.""" + + type: str + + model_config = ConfigDict(extra="allow") + + if TYPE_CHECKING: + # to hint that we're gonna fill this with arbitrary data belonging to some supposed battle type + def __getattr__(self, __attr: str) -> Any: + ... + class UiData(BaseModel): """Object containing custom diplay data. @@ -280,11 +331,12 @@ def load_entrypoints(cls) -> None: if not (isclass(battle) and issubclass(battle, Battle)): raise ValueError(f"Entrypoint {entrypoint.name} targets something other than a Battle type") - def __init_subclass__(cls) -> None: + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: if cls.name() not in Battle._battle_types: Battle._battle_types[cls.name()] = cls Battle.Config.model_rebuild(force=True) - return super().__init_subclass__() + return super().__pydantic_init_subclass__(**kwargs) @abstractmethod def score(self) -> float: diff --git a/algobattle/cli.py b/algobattle/cli.py index 6cc6326b..710b0cb0 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -41,7 +41,7 @@ from tomlkit.items import Table as TomlTable from algobattle.battle import Battle -from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig, MatchConfig +from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig from algobattle.problem import Instance, Problem from algobattle.program import Generator, Matchup, Solver from algobattle.util import EncodableModel, ExceptionInfo, Role, RunningTimer, BaseModel, TempDir @@ -209,8 +209,7 @@ def init( problem_zip.extractall(build_dir) problem_config = parse_toml((build_dir / "config.toml").read_text()) - # ! paths in this config arent properly relativized - parsed_config = AlgobattleConfig.model_validate(problem_config) + parsed_config = AlgobattleConfig.from_file(build_dir / "config.toml", ignore_uninstalled=True) problem_name = parsed_config.match.problem assert isinstance(problem_name, str) if target is None: @@ -256,8 +255,7 @@ def init( console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort problem_config = parse_toml(target.joinpath("config.toml").read_text()) - # ! paths in this config arent properly relativized - parsed_config = AlgobattleConfig.model_validate(problem_config) + parsed_config = AlgobattleConfig.from_file(target / "config.toml", ignore_uninstalled=True) problem_name = parsed_config.match.problem with Status("Initializing metadata"): @@ -273,8 +271,6 @@ def init( problem_config["execution"] = config.default_exec (target / "config.toml").write_text(dumps_toml(problem_config)) res_path = parsed_config.execution.results - if not res_path.is_absolute(): - res_path = (target / res_path).resolve() res_path.mkdir(parents=True, exist_ok=True) problem_obj = Problem.load(problem_name) diff --git a/algobattle/match.py b/algobattle/match.py index 8c466167..c2525bce 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -20,7 +20,7 @@ from algobattle.battle import Battle, FightHandler, FightUi, BattleUi, Iterated from algobattle.program import ProgramConfigView, ProgramUi, Matchup, TeamHandler, Team, BuildUi -from algobattle.problem import InstanceT, Problem, ProblemName, SolutionT +from algobattle.problem import InstanceT, Problem, SolutionT from algobattle.util import ( ExceptionInfo, MatchMode, @@ -559,6 +559,16 @@ class RunConfig(BaseModel): """Number of cpu cores available.""" +def _check_problem_name(val: str, info: ValidationInfo) -> str: + if (info.context is not None and info.context.get("ignore_uninstalled", False)) or val in Problem.available(): + return val + else: + raise ValueError("Value is not the name of an installed Problem.") + + +ProblemName = Annotated[str, AfterValidator(_check_problem_name)] + + class MatchConfig(BaseModel): """Parameters determining the match execution. @@ -632,21 +642,26 @@ def problem(self) -> Problem[Any, Any] | None: return Problem.load(self.match.problem) @classmethod - def from_file(cls, file: Path) -> Self: + def from_file(cls, file: Path, ignore_uninstalled: bool = False) -> Self: """Parses a config object from a toml file. - If the file doesn't exist it returns a default instance instead of raising an error. + Args: + file: Path to the file, or a directory containing one called 'config.toml'. + ignore_uninstalled: Whether to raise errors if the specified problem and battle type cannot be found. """ Battle.load_entrypoints() if not file.is_file(): - config_dict = {} - else: - with open(file, "rb") as f: - try: - config_dict = tomllib.load(f) - except tomllib.TOMLDecodeError as e: - raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}") - return cls.model_validate(config_dict, context={"base_path": file.parent}) + if file.joinpath("config.toml").is_file(): + file /= "config.toml" + else: + raise ValueError("The path does not point to a config file") + try: + config_dict = tomllib.loads(file.read_text()) + except tomllib.TOMLDecodeError as e: + raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}") + return cls.model_validate( + config_dict, context={"base_path": file.parent, "ignore_uninstalled": ignore_uninstalled} + ) def as_prog_config(self) -> ProgramConfigView: """Builds a simple object containing all program relevant settings.""" diff --git a/algobattle/problem.py b/algobattle/problem.py index 76339322..b6ccf8a2 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -1,10 +1,10 @@ """Module defining the Problem and Solution base classes and related objects.""" from abc import ABC, abstractmethod from functools import wraps +from importlib.metadata import entry_points from pathlib import Path from typing import ( TYPE_CHECKING, - Annotated, Any, Callable, ClassVar, @@ -18,14 +18,11 @@ ) from math import inf, isnan -from pydantic import AfterValidator - from algobattle.util import ( EncodableModel, InstanceSolutionModel, Role, Encodable, - problem_entrypoints, ) @@ -271,6 +268,7 @@ def __init__( self.with_solution = with_solution self.score_function = score_function self.test_instance = test_instance + self._installed[name] = self __slots__ = ("name", "instance_cls", "solution_cls", "min_size", "with_solution", "score_function", "test_instance") _installed: "ClassVar[dict[str, AnyProblem]]" = {} @@ -306,27 +304,32 @@ def score( return self.score_function(instance, solution=solution) @classmethod - def load(cls, problem: "ProblemName") -> "AnyProblem": + def load(cls, name: str) -> "AnyProblem": """Gets either an installed problem instance using its name or imports an entrypoint.""" - if problem in cls._installed: - return cls._installed[problem] - else: - try: - loaded = problem_entrypoints()[problem].load() + if name in cls._installed: + return cls._installed[name] + match list(entry_points(group="algobattle.problem", name=name)): + case []: + raise ValueError("Problem name is not valid.") + case [e]: + loaded: object = e.load() if not isinstance(loaded, cls): - raise RuntimeError(f"The entrypoint '{problem}' doesn't point to a problem but rather: {problem}.") + raise RuntimeError( + f"The entrypoint '{name}' doesn't point to a problem but a {loaded.__class__.__qualname__}." + ) return loaded - except KeyError: - raise ValueError("Problem name is not valid.") - + case entypoints: + raise ValueError( + f"Multiple problem entrypoints with the name {name} exist!" + f" The modules providing them are: {', '.join(e.module for e in entypoints)}." + ) -def _check_problem_name(val: str) -> str: - if val not in Problem._installed and val not in problem_entrypoints(): - raise ValueError("Value is not the name of an installed Problem.") - return val + @classmethod + def available(cls) -> set[str]: + """Returns the names of all available Problems.""" + return set(*cls._installed.keys(), *(e.name for e in entry_points(group="algobattle.problem"))) -ProblemName = Annotated[str, AfterValidator(_check_problem_name)] AnyProblem = Problem[Any, Any] diff --git a/algobattle/util.py b/algobattle/util.py index d572e805..ee354ae5 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -397,16 +397,6 @@ def can_be_positional(param: Parameter) -> bool: return param.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) -def problem_entrypoints() -> dict[str, EntryPoint]: - """Returns all currently registered problem entrypoints.""" - return {e.name: e for e in entry_points(group="algobattle.problem")} - - -def battle_entrypoints() -> dict[str, EntryPoint]: - """Returns all currently registered battle entrypoints.""" - return {e.name: e for e in entry_points(group="algobattle.battle")} - - class TempDir(TemporaryDirectory): """Python's `TemporaryDirectory` but with a contextmanager returning a Path.""" From e545b944e28203b7b372531a65123fff7241e696 Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 01:35:48 +0200 Subject: [PATCH 022/113] cleanup cli status indicators --- algobattle/cli.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 710b0cb0..8555f9b4 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -35,7 +35,6 @@ from rich.panel import Panel from rich.text import Text from rich.columns import Columns -from rich.status import Status from rich.prompt import Prompt, Confirm from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table from tomlkit.items import Table as TomlTable @@ -166,7 +165,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: return else: dir.mkdir(parents=True, exist_ok=True) - with Status(f"Initializing {role}"): + with console.status(f"Initializing {role}"): write_templates(dir, lang, TemplateArgs(program=role.value, **args)) @@ -204,7 +203,7 @@ def init( problem = Path("problem.aprb") if problem is not None: with TempDir() as build_dir: - with Status("Extracting problem data"): + with console.status("Extracting problem data"): with ZipFile(problem) as problem_zip: problem_zip.extractall(build_dir) @@ -234,14 +233,8 @@ def init( if new_problem: cmd = config.install_cmd(build_dir) - with Status("Installing problem"): - res = run_process( - cmd, - shell=False, - capture_output=True, - text=True, - env=environ.copy(), - ) + with console.status("Installing problem"): + res = run_process(cmd, env=environ.copy()) if res.returncode: print("Couldn't install the problem") console.print(f"[red]{res.stderr}") @@ -258,7 +251,7 @@ def init( parsed_config = AlgobattleConfig.from_file(target / "config.toml", ignore_uninstalled=True) problem_name = parsed_config.match.problem - with Status("Initializing metadata"): + with console.status("Initializing metadata"): if "teams" not in problem_config: problem_config.add( "teams", @@ -323,11 +316,11 @@ def test( if generator: async def gen_builder() -> Generator: - with Status("Building generator"): + with console.status("Building generator"): return await Generator.build(team_obj.generator, problem=problem, config=config.as_prog_config()) with run_async_fn(gen_builder) as gen: - with Status("Running generator"): + with console.status("Running generator"): gen_instance = gen.test() if isinstance(gen_instance, ExceptionInfo): console.print("[red]The generator didn't run successfully.") @@ -349,11 +342,11 @@ async def gen_builder() -> Generator: instance = gen_instance async def sol_builder() -> Solver: - with Status("Building solver"): + with console.status("Building solver"): return await Solver.build(team_obj.generator, problem=problem, config=config.as_prog_config()) with run_async_fn(sol_builder) as sol: - with Status("Running solver"): + with console.status("Running solver"): sol_error = sol.test(instance) if isinstance(sol_error, ExceptionInfo): console.print("[red]The solver didn't run successfully.") From 78f8c952c40412cfeda52181986123c92b066c91 Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 01:57:43 +0200 Subject: [PATCH 023/113] relay output from pip installer to cli --- algobattle/cli.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 8555f9b4..3ce19e95 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -8,7 +8,7 @@ from pathlib import Path from random import choice from shutil import rmtree -from subprocess import run as run_process +from subprocess import PIPE, Popen, run as run_process import sys from typing import Annotated, Any, ClassVar, Iterable, Literal, Optional, Self, cast from typing_extensions import override @@ -233,11 +233,16 @@ def init( if new_problem: cmd = config.install_cmd(build_dir) - with console.status("Installing problem"): - res = run_process(cmd, env=environ.copy()) - if res.returncode: - print("Couldn't install the problem") - console.print(f"[red]{res.stderr}") + with console.status("Installing problem"), Popen( + cmd, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True + ) as installer: + assert installer.stdout is not None + assert installer.stderr is not None + for line in installer.stdout: + console.print(line.strip("\n")) + error = "".join(installer.stderr.readlines()) + if installer.returncode: + console.print(f"[red]Couldn't install the problem[/]\n{error}") raise Abort for path in problem_data: path.rename(target / path.name) From 8e811bc3e6b11177cdba3c197e89f0a54f27d9d8 Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 02:24:05 +0200 Subject: [PATCH 024/113] log program creation --- algobattle/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/algobattle/cli.py b/algobattle/cli.py index 3ce19e95..7fa2aadf 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -167,6 +167,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: dir.mkdir(parents=True, exist_ok=True) with console.status(f"Initializing {role}"): write_templates(dir, lang, TemplateArgs(program=role.value, **args)) + console.print(f"Created a {lang.value} {role.value} in [cyan]{dir}") @app.command() From d5d89ef77abf3241bb24099cfc3c3e5194a9ee4f Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 02:28:46 +0200 Subject: [PATCH 025/113] add install flag to init command --- algobattle/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 7fa2aadf..17a5909b 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -186,6 +186,7 @@ def init( Optional[Language], Option("--generator", "-g", help="The language to use for the generator.") ] = None, solver: Annotated[Optional[Language], Option("--solver", "-s", help="The language to use for the solver.")] = None, + install: Annotated[bool, Option(help="Whether to install the problem package.")] = True, ) -> None: """Initializes a project directory, setting up the problem files and program folders with docker files. @@ -232,7 +233,7 @@ def init( else: new_problem = False - if new_problem: + if new_problem and install: cmd = config.install_cmd(build_dir) with console.status("Installing problem"), Popen( cmd, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True From eed62038ff68746195acbae53c809e220167e8ef Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 02:49:15 +0200 Subject: [PATCH 026/113] properly relativize config paths --- algobattle/battle.py | 2 +- algobattle/cli.py | 12 +++++++++--- algobattle/match.py | 10 ++++++---- algobattle/util.py | 1 - 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/algobattle/battle.py b/algobattle/battle.py index 3968925f..eb12fa2c 100644 --- a/algobattle/battle.py +++ b/algobattle/battle.py @@ -32,7 +32,7 @@ ValidatorFunctionWrapHandler, ) from pydantic_core import CoreSchema -from pydantic_core.core_schema import tagged_union_schema, union_schema, general_wrap_validator_function +from pydantic_core.core_schema import tagged_union_schema, general_wrap_validator_function from algobattle.program import ( Generator, diff --git a/algobattle/cli.py b/algobattle/cli.py index 17a5909b..d1365f8d 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -8,7 +8,7 @@ from pathlib import Path from random import choice from shutil import rmtree -from subprocess import PIPE, Popen, run as run_process +from subprocess import PIPE, Popen import sys from typing import Annotated, Any, ClassVar, Iterable, Literal, Optional, Self, cast from typing_extensions import override @@ -210,7 +210,9 @@ def init( problem_zip.extractall(build_dir) problem_config = parse_toml((build_dir / "config.toml").read_text()) - parsed_config = AlgobattleConfig.from_file(build_dir / "config.toml", ignore_uninstalled=True) + parsed_config = AlgobattleConfig.from_file( + build_dir / "config.toml", ignore_uninstalled=True, reltivize_paths=False + ) problem_name = parsed_config.match.problem assert isinstance(problem_name, str) if target is None: @@ -255,7 +257,9 @@ def init( console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort problem_config = parse_toml(target.joinpath("config.toml").read_text()) - parsed_config = AlgobattleConfig.from_file(target / "config.toml", ignore_uninstalled=True) + parsed_config = AlgobattleConfig.from_file( + target / "config.toml", ignore_uninstalled=True, reltivize_paths=False + ) problem_name = parsed_config.match.problem with console.status("Initializing metadata"): @@ -271,6 +275,8 @@ def init( problem_config["execution"] = config.default_exec (target / "config.toml").write_text(dumps_toml(problem_config)) res_path = parsed_config.execution.results + if not res_path.absolute(): + res_path = target / res_path res_path.mkdir(parents=True, exist_ok=True) problem_obj = Problem.load(problem_name) diff --git a/algobattle/match.py b/algobattle/match.py index c2525bce..df4ecf0c 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -642,12 +642,13 @@ def problem(self) -> Problem[Any, Any] | None: return Problem.load(self.match.problem) @classmethod - def from_file(cls, file: Path, ignore_uninstalled: bool = False) -> Self: + def from_file(cls, file: Path, ignore_uninstalled: bool = False, reltivize_paths: bool = True) -> Self: """Parses a config object from a toml file. Args: file: Path to the file, or a directory containing one called 'config.toml'. ignore_uninstalled: Whether to raise errors if the specified problem and battle type cannot be found. + reltivize_paths: Wether to relativize paths to the config's location rather than the cwd. """ Battle.load_entrypoints() if not file.is_file(): @@ -659,9 +660,10 @@ def from_file(cls, file: Path, ignore_uninstalled: bool = False) -> Self: config_dict = tomllib.loads(file.read_text()) except tomllib.TOMLDecodeError as e: raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}") - return cls.model_validate( - config_dict, context={"base_path": file.parent, "ignore_uninstalled": ignore_uninstalled} - ) + context: dict[str, Any] = {"ignore_uninstalled": ignore_uninstalled} + if reltivize_paths: + context["base_path"] = file.parent + return cls.model_validate(config_dict, context=context) def as_prog_config(self) -> ProgramConfigView: """Builds a simple object containing all program relevant settings.""" diff --git a/algobattle/util.py b/algobattle/util.py index ee354ae5..be6ab76c 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -14,7 +14,6 @@ from traceback import format_exception from typing import Any, Callable, ClassVar, Iterable, Literal, LiteralString, TypeVar, Self, cast, get_args from annotated_types import GroupedMetadata -from importlib.metadata import EntryPoint, entry_points from pydantic import ( ConfigDict, From 162bf00e42140ff635dfaed601aaf25f8e34483a Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 03:06:34 +0200 Subject: [PATCH 027/113] fix path relativization validator --- algobattle/match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/match.py b/algobattle/match.py index df4ecf0c..65823151 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -343,7 +343,7 @@ def parse_none(value: Any) -> Any | None: def _relativize_path(path: Path, info: ValidationInfo) -> Path: """If the passed path is relative to the current directory it gets relativized to the `base_path` instead.""" - if info.context and isinstance(info.context["base_path"], Path) and not path.is_absolute(): + if info.context and isinstance(info.context.get("base_path", None), Path) and not path.is_absolute(): return info.context["base_path"] / path return path From 7f736fdf3948ff4f4eef7ef2756cf43f5950c82c Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 03:07:04 +0200 Subject: [PATCH 028/113] add gitignore to project folder --- algobattle/cli.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index d1365f8d..a8f6e8af 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -173,7 +173,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: @app.command() def init( target: Annotated[ - Optional[Path], Argument(file_okay=False, writable=True, help="The folder to initialize.") + Optional[Path], Argument(file_okay=False, writable=True, resolve_path=True, help="The folder to initialize.") ] = None, problem: Annotated[ Optional[Path], @@ -252,7 +252,7 @@ def init( path.rename(target / path.name) else: if target is None: - target = Path() + target = Path().resolve() if not target.joinpath("config.toml").is_file(): console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort @@ -275,9 +275,11 @@ def init( problem_config["execution"] = config.default_exec (target / "config.toml").write_text(dumps_toml(problem_config)) res_path = parsed_config.execution.results - if not res_path.absolute(): + if not res_path.is_absolute(): res_path = target / res_path res_path.mkdir(parents=True, exist_ok=True) + if res_path.is_relative_to(target): + target.joinpath(".gitignore").write_text(f"{res_path.relative_to(target)}/\n") problem_obj = Problem.load(problem_name) template_args: PartialTemplateArgs = { From 1ed328e0919edd562d8080648a2b158ed4e3f270 Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 05:04:57 +0200 Subject: [PATCH 029/113] fix Problem.available() --- algobattle/problem.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/algobattle/problem.py b/algobattle/problem.py index b6ccf8a2..aeeabb91 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from functools import wraps from importlib.metadata import entry_points +from itertools import chain from pathlib import Path from typing import ( TYPE_CHECKING, @@ -327,7 +328,7 @@ def load(cls, name: str) -> "AnyProblem": @classmethod def available(cls) -> set[str]: """Returns the names of all available Problems.""" - return set(*cls._installed.keys(), *(e.name for e in entry_points(group="algobattle.problem"))) + return set(chain(cls._installed.keys(), (e.name for e in entry_points(group="algobattle.problem")))) AnyProblem = Problem[Any, Any] From 3a1128f5e3725b30b4d376cbd3bce42298bd6648 Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 05:15:35 +0200 Subject: [PATCH 030/113] simplify init logic --- algobattle/cli.py | 111 ++++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 48 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index a8f6e8af..4cb077db 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -94,7 +94,7 @@ def save(self) -> None: @property def default_exec(self) -> TomlTable | None: """The default exec config for each problem.""" - exec: Any = self._doc.get("exec", None) + exec: Any = self._doc.get("execution", None) return exec def install_cmd(self, target: Path) -> list[str]: @@ -173,7 +173,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: @app.command() def init( target: Annotated[ - Optional[Path], Argument(file_okay=False, writable=True, resolve_path=True, help="The folder to initialize.") + Optional[Path], Argument(file_okay=False, writable=True, help="The folder to initialize.") ] = None, problem: Annotated[ Optional[Path], @@ -186,7 +186,6 @@ def init( Optional[Language], Option("--generator", "-g", help="The language to use for the generator.") ] = None, solver: Annotated[Optional[Language], Option("--solver", "-s", help="The language to use for the solver.")] = None, - install: Annotated[bool, Option(help="Whether to install the problem package.")] = True, ) -> None: """Initializes a project directory, setting up the problem files and program folders with docker files. @@ -201,84 +200,100 @@ def init( config = CliConfig.load() team_name = config.general.team_name or choice(("Dogs", "Cats", "Otters", "Red Pandas", "Possums", "Rats")) - if problem is None and Path("problem.aprb").is_file(): - problem = Path("problem.aprb") if problem is not None: - with TempDir() as build_dir: + with TempDir() as unpack_dir: with console.status("Extracting problem data"): with ZipFile(problem) as problem_zip: - problem_zip.extractall(build_dir) + problem_zip.extractall(unpack_dir) - problem_config = parse_toml((build_dir / "config.toml").read_text()) parsed_config = AlgobattleConfig.from_file( - build_dir / "config.toml", ignore_uninstalled=True, reltivize_paths=False + unpack_dir / "config.toml", ignore_uninstalled=True, reltivize_paths=False ) - problem_name = parsed_config.match.problem - assert isinstance(problem_name, str) if target is None: - target = Path() if Path().name == problem_name else Path() / problem_name - target.mkdir(parents=True, exist_ok=True) + target = Path() / parsed_config.match.problem - new_problem = True - problem_data = list(build_dir.iterdir()) + target.mkdir(parents=True, exist_ok=True) + problem_data = list(unpack_dir.iterdir()) if any(((target / path.name).exists() for path in problem_data)): - replace = Confirm.ask( - "[magenta2]The target directory already contains problem data, do you want to replace it?", + copy_problem_data = Confirm.ask( + "[magenta2]The target directory already contains an algobattle project, do you want to replace it?", default=True, ) - if replace: - for path in problem_data: - if (file := target / path.name).is_file(): - file.unlink() - elif (dir := target / path.name).is_dir(): - rmtree(dir) - else: - new_problem = False - - if new_problem and install: - cmd = config.install_cmd(build_dir) - with console.status("Installing problem"), Popen( - cmd, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True - ) as installer: - assert installer.stdout is not None - assert installer.stderr is not None - for line in installer.stdout: - console.print(line.strip("\n")) - error = "".join(installer.stderr.readlines()) - if installer.returncode: - console.print(f"[red]Couldn't install the problem[/]\n{error}") - raise Abort + else: + copy_problem_data = True + if copy_problem_data: for path in problem_data: + if (file := target / path.name).is_file(): + file.unlink() + elif (dir := target / path.name).is_dir(): + rmtree(dir) path.rename(target / path.name) + console.print("Unpacked problem data") + else: + parsed_config = AlgobattleConfig.from_file( + target / "config.toml", ignore_uninstalled=True, reltivize_paths=False + ) + console.print("Using existing problem data") else: if target is None: - target = Path().resolve() + target = Path() if not target.joinpath("config.toml").is_file(): console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort - problem_config = parse_toml(target.joinpath("config.toml").read_text()) parsed_config = AlgobattleConfig.from_file( target / "config.toml", ignore_uninstalled=True, reltivize_paths=False ) - problem_name = parsed_config.match.problem + console.print("Using existing problem data") + + problem_name = parsed_config.match.problem + if problem_name not in Problem.available(): + existing_data = set(p.resolve() for p in target.iterdir()) + cmd = config.install_cmd(target) + try: + with console.status("Installing problem"), Popen( + cmd, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True + ) as installer: + assert installer.stdout is not None + assert installer.stderr is not None + for line in installer.stdout: + console.print(line.strip("\n")) + error = "".join(installer.stderr.readlines()) + # pip leaves behind some build artifacts we want to clean up + finally: + for path in target.iterdir(): + path = path.resolve() + if path in existing_data: + continue + elif path.is_file(): + path.unlink() + elif path.is_dir(): + rmtree(path) + if installer.returncode: + console.print(f"[red]Couldn't install the problem[/]\n{error}") + raise Abort + else: + console.print(f"Installed problem {problem_name}") + else: + console.print(f"{problem_name} problem already is installed") with console.status("Initializing metadata"): - if "teams" not in problem_config: - problem_config.add( + config_doc = parse_toml(target.joinpath("config.toml").read_text()) + if "teams" not in config_doc: + config_doc.add( "teams", table().add( team_name, table().add("generator", "./generator").add("solver", "./solver"), ), ) - if config.default_exec is not None and "execution" not in problem_config: - problem_config["execution"] = config.default_exec - (target / "config.toml").write_text(dumps_toml(problem_config)) + if config.default_exec is not None and "execution" not in config_doc: + config_doc["execution"] = config.default_exec + (target / "config.toml").write_text(dumps_toml(config_doc)) res_path = parsed_config.execution.results if not res_path.is_absolute(): res_path = target / res_path res_path.mkdir(parents=True, exist_ok=True) - if res_path.is_relative_to(target): + if res_path.resolve().is_relative_to(target.resolve()): target.joinpath(".gitignore").write_text(f"{res_path.relative_to(target)}/\n") problem_obj = Problem.load(problem_name) From b5923a1c61400f5f293402259da3b60bf6ce6578 Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 05:28:04 +0200 Subject: [PATCH 031/113] improve error message --- algobattle/match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/match.py b/algobattle/match.py index 65823151..c252ca99 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -655,7 +655,7 @@ def from_file(cls, file: Path, ignore_uninstalled: bool = False, reltivize_paths if file.joinpath("config.toml").is_file(): file /= "config.toml" else: - raise ValueError("The path does not point to a config file") + raise ValueError("The path does not point to an Algobattle project") try: config_dict = tomllib.loads(file.read_text()) except tomllib.TOMLDecodeError as e: From 6734f2ede04769902b2be0fe65b86e6df8804a13 Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 06:07:05 +0200 Subject: [PATCH 032/113] only cleanup program if in tournament mode --- algobattle/program.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/algobattle/program.py b/algobattle/program.py index 38077882..6a9584c2 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -480,7 +480,8 @@ def __enter__(self): return self def __exit__(self, _type: Any, _value: Any, _traceback: Any): - self.remove() + if self.config.mode == "tournament": + self.remove() class Generator(Program): From 792bd65912378ec2f09624796b9caf478474dc8b Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 06:21:05 +0200 Subject: [PATCH 033/113] properly pass through program build settings --- algobattle/program.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/algobattle/program.py b/algobattle/program.py index 6a9584c2..cb6bcb56 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -266,8 +266,9 @@ async def build( Raises: BuildError: If the build fails for any reason. """ - if team_name is not None: - name = f"algobattle_{team_name}_{cls.role.name}" + if team_name is not None and config.mode == "testing": + normalized = team_name.lower().replace(" ", "_") + name = f"algobattle_{normalized}_{cls.role.name}" try: old_image = cast(DockerImage, client().images.get(name)) except ImageNotFound: @@ -745,13 +746,12 @@ async def build( ValueError: If the team name is already in use. DockerError: If the docker build fails for some reason """ - tag_name = name.lower().replace(" ", "_") if config.mode == "testing" else None ui.start_build(name, Role.generator) generator = await Generator.build( path=info.generator, problem=problem, config=config, - team_name=tag_name, + team_name=name, ) try: ui.start_build(name, Role.solver) @@ -759,7 +759,7 @@ async def build( path=info.solver, problem=problem, config=config, - team_name=tag_name, + team_name=name, ) except Exception: generator.remove() @@ -778,11 +778,14 @@ def __eq__(self, o: object) -> bool: def __hash__(self) -> int: return hash(self.name) - def __enter__(self): + def __enter__(self) -> Self: + self.generator.__enter__() + self.solver.__enter__() return self - def __exit__(self, _type: Any, _value: Any, _traceback: Any): - self.cleanup() + def __exit__(self, *args: Any): + self.generator.__exit__(*args) + self.solver.__exit__(*args) def cleanup(self) -> None: """Removes the built docker images.""" @@ -851,12 +854,13 @@ async def build( return handler def __enter__(self) -> Self: + for team in self.active: + team.__enter__() return self - def __exit__(self, _type: Any, _value: Any, _traceback: Any): - if self.cleanup: - for team in self.active: - team.cleanup() + def __exit__(self, *args: Any): + for team in self.active: + team.__exit__(*args) @property def grouped_matchups(self) -> list[tuple[Matchup, Matchup]]: From 240db39e730d9456e5407d9e584a41495eca1c0c Mon Sep 17 00:00:00 2001 From: Imogen Date: Fri, 15 Sep 2023 06:21:27 +0200 Subject: [PATCH 034/113] log test command errors --- algobattle/cli.py | 90 ++++++++++++++++++++++++++++++---------------- algobattle/util.py | 6 ++++ 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 4cb077db..6fbc3bbc 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -2,7 +2,6 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ -from datetime import datetime from enum import StrEnum from os import environ from pathlib import Path @@ -43,7 +42,7 @@ from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig from algobattle.problem import Instance, Problem from algobattle.program import Generator, Matchup, Solver -from algobattle.util import EncodableModel, ExceptionInfo, Role, RunningTimer, BaseModel, TempDir +from algobattle.util import BuildError, EncodableModel, ExceptionInfo, Role, RunningTimer, BaseModel, TempDir, timestamp from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates @@ -142,10 +141,8 @@ def run_match( print(f"Team {team} gained {pts:.1f} points.") if save: - t = datetime.now() - filename = f"{t.year:04d}-{t.month:02d}-{t.day:02d}_{t.hour:02d}-{t.minute:02d}-{t.second:02d}.json" - with open(config.execution.results / filename, "w+") as f: - f.write(result.model_dump_json(exclude_defaults=True)) + res_string = result.model_dump_json(exclude_defaults=True) + config.execution.results.joinpath(f"{timestamp()}.json").write_text(res_string) return result except KeyboardInterrupt: raise Exit @@ -312,9 +309,18 @@ def init( console.print(f"[green]Success![/] initialized algobattle project data in [cyan]{target}[/]") +class TestErrors(BaseModel): + """Helper class holding test error messages.""" + + generator_build: ExceptionInfo | None = None + solver_build: ExceptionInfo | None = None + generator_run: ExceptionInfo | None = None + solver_run: ExceptionInfo | None = None + + @app.command() def test( - folder: Annotated[Path, Argument(file_okay=False, writable=True, help="The problem folder to use.")] = Path(), + folder: Annotated[Path, Argument(help="The project folder to use.")] = Path(), generator: Annotated[bool, Option(help="Whether to test the generator")] = True, solver: Annotated[bool, Option(help="Whether to test the solver")] = True, team: Annotated[Optional[str], Option(help="Name of the team whose programs you want to test.")] = None, @@ -322,12 +328,13 @@ def test( """Tests whether the programs install successfully and run on dummy instances without crashing.""" config = AlgobattleConfig.from_file(folder) problem = config.problem + errors = TestErrors() if problem is None: print(f"The problem specified in the config file ({config.match.problem}) is not installed.") raise Abort if team: try: - team_obj = config.teams[team] + team_info = config.teams[team] except KeyError: console.print("[red]The specified team does not exist in the config file.") raise Abort @@ -337,29 +344,40 @@ def test( console.print("[red]The config file contains no teams.") raise Abort case 1: - team_obj = next(iter(config.teams.values())) + team, team_info = next(iter(config.teams.items())) case _: console.print("[red]The config file contains more than one team and none were specified.") raise Abort - gen_instance = None + console.print(f"Testing {team}'s programs") + instance = None if generator: async def gen_builder() -> Generator: with console.status("Building generator"): - return await Generator.build(team_obj.generator, problem=problem, config=config.as_prog_config()) + return await Generator.build( + team_info.generator, problem=problem, config=config.as_prog_config(), team_name=team + ) - with run_async_fn(gen_builder) as gen: - with console.status("Running generator"): - gen_instance = gen.test() - if isinstance(gen_instance, ExceptionInfo): - console.print("[red]The generator didn't run successfully.") - config.execution.results.write_text(gen_instance.model_dump_json()) - gen_instance = None + try: + with run_async_fn(gen_builder) as gen: + console.print("[green]Generator built successfully") + with console.status("Running generator"): + instance = gen.test() + if isinstance(instance, ExceptionInfo): + console.print("[red]Generator didn't run successfully") + errors.generator_run = instance + instance = None + else: + console.print("[green]Generator ran successfully") + except BuildError as e: + console.print("[red]Generator didn't build successfully") + errors.generator_build = ExceptionInfo.from_exception(e) + instance = None sol_error = None if solver: - if gen_instance is None: + if instance is None: if problem.test_instance is None: console.print( "[magenta2]Cannot test the solver since the generator failed and the problem doesn't provide a test" @@ -368,22 +386,32 @@ async def gen_builder() -> Generator: raise Exit else: instance = cast(Instance, problem.test_instance) - else: - instance = gen_instance async def sol_builder() -> Solver: with console.status("Building solver"): - return await Solver.build(team_obj.generator, problem=problem, config=config.as_prog_config()) - - with run_async_fn(sol_builder) as sol: - with console.status("Running solver"): - sol_error = sol.test(instance) - if isinstance(sol_error, ExceptionInfo): - console.print("[red]The solver didn't run successfully.") - config.execution.results.write_text(sol_error.model_dump_json()) + return await Solver.build( + team_info.solver, problem=problem, config=config.as_prog_config(), team_name=team + ) - if gen_instance is not None and sol_error is None: - console.print("[green]Both programs tested successfully.") + try: + with run_async_fn(sol_builder) as sol: + console.print("[green]Solver built successfully") + with console.status("Running solver"): + sol_error = sol.test(instance) + if isinstance(sol_error, ExceptionInfo): + console.print("[red]Solver didn't run successfully") + errors.solver_run = sol_error + else: + console.print("[green]Solver ran successfully") + except BuildError as e: + console.print("[red]Solver didn't build successfully") + errors.solver_build = ExceptionInfo.from_exception(e) + instance = None + + if errors != TestErrors(): + err_path = config.execution.results.joinpath(f"{timestamp()}.json") + err_path.write_text(errors.model_dump_json(indent=4, exclude_defaults=True)) + console.print(f"You can find detailed error messages at {err_path}") @app.command() diff --git a/algobattle/util.py b/algobattle/util.py index be6ab76c..9d6f9fae 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -401,3 +401,9 @@ class TempDir(TemporaryDirectory): def __enter__(self) -> Path: return Path(super().__enter__()) + + +def timestamp() -> str: + """Formats the current time into a filename-safe string.""" + t = datetime.now() + return f"{t.year:04d}-{t.month:02d}-{t.day:02d}_{t.hour:02d}-{t.minute:02d}-{t.second:02d}" From 547a52d5dabef7823f63b6805c90cf31411fc3e6 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 07:54:53 +0200 Subject: [PATCH 035/113] add dynamic importing of problems properly --- algobattle/cli.py | 11 ++++------- algobattle/match.py | 41 +++++++++++++++++++++++++---------------- algobattle/problem.py | 37 ++++++++++++++++++++++++++++++------- algobattle/util.py | 27 +++++++++++++++++++++++++++ tests/test_match.py | 2 +- 5 files changed, 87 insertions(+), 31 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 6fbc3bbc..a3c73e61 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -204,7 +204,7 @@ def init( problem_zip.extractall(unpack_dir) parsed_config = AlgobattleConfig.from_file( - unpack_dir / "config.toml", ignore_uninstalled=True, reltivize_paths=False + unpack_dir / "config.toml", reltivize_paths=False ) if target is None: target = Path() / parsed_config.match.problem @@ -228,7 +228,7 @@ def init( console.print("Unpacked problem data") else: parsed_config = AlgobattleConfig.from_file( - target / "config.toml", ignore_uninstalled=True, reltivize_paths=False + target / "config.toml", reltivize_paths=False ) console.print("Using existing problem data") else: @@ -238,7 +238,7 @@ def init( console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort parsed_config = AlgobattleConfig.from_file( - target / "config.toml", ignore_uninstalled=True, reltivize_paths=False + target / "config.toml", reltivize_paths=False ) console.print("Using existing problem data") @@ -293,7 +293,7 @@ def init( if res_path.resolve().is_relative_to(target.resolve()): target.joinpath(".gitignore").write_text(f"{res_path.relative_to(target)}/\n") - problem_obj = Problem.load(problem_name) + problem_obj = parsed_config.problem template_args: PartialTemplateArgs = { "problem": problem_name, "team": team_name, @@ -329,9 +329,6 @@ def test( config = AlgobattleConfig.from_file(folder) problem = config.problem errors = TestErrors() - if problem is None: - print(f"The problem specified in the config file ({config.match.problem}) is not installed.") - raise Abort if team: try: team_info = config.teams[team] diff --git a/algobattle/match.py b/algobattle/match.py index c252ca99..e040a75f 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -91,7 +91,7 @@ async def run( if ui is None: ui = EmptyUi() ui.match = self - problem = Problem.load(config.match.problem) + problem = Problem.load(config.match.problem, config.problems) with await TeamHandler.build(config.teams, problem, config.as_prog_config(), ui) as teams: self.active_teams = [t.name for t in teams.active] @@ -559,23 +559,13 @@ class RunConfig(BaseModel): """Number of cpu cores available.""" -def _check_problem_name(val: str, info: ValidationInfo) -> str: - if (info.context is not None and info.context.get("ignore_uninstalled", False)) or val in Problem.available(): - return val - else: - raise ValueError("Value is not the name of an installed Problem.") - - -ProblemName = Annotated[str, AfterValidator(_check_problem_name)] - - class MatchConfig(BaseModel): """Parameters determining the match execution. It will be parsed from the given config file and contains all settings that specify how the match is run. """ - problem: ProblemName + problem: str """The problem this match is over.""" build_timeout: WithNone[TimeDeltaFloat] = 600 """Timeout for building each docker image.""" @@ -589,6 +579,15 @@ class MatchConfig(BaseModel): model_config = ConfigDict(revalidate_instances="always") +class DynamicProblemConfig(BaseModel): + """Defines metadata used to dynamically import problems.""" + + location: RelativeFilePath = Field(default=Path("problem.py"), validate_default=True) + """Path to the file defining the problem""" + dependencies: list[str] = Field(default_factory=list) + """List of dependencies needed to run the problem""" + + class ExecutionConfig(BaseModel): """Settings that only determine how a match is run, not its result.""" @@ -633,21 +632,31 @@ class AlgobattleConfig(BaseModel): match: MatchConfig battle: Battle.Config = Iterated.Config() docker: DockerConfig = DockerConfig() + problems: dict[str, DynamicProblemConfig] = Field(default_factory=dict) model_config = ConfigDict(revalidate_instances="always") + @model_validator(mode="after") + def check_problem_defined(self) -> Self: + """Validates that the specified problem is either installed or dynamically specified.""" + prob = self.match.problem + if prob not in self.problems and prob not in Problem.available(): + raise ValueError(f"The specified problem {prob} cannot be found") + else: + return self + @cached_property - def problem(self) -> Problem[Any, Any] | None: + def problem(self) -> Problem[Any, Any]: """The problem this config uses.""" - return Problem.load(self.match.problem) + return Problem.load(self.match.problem, self.problems) @classmethod - def from_file(cls, file: Path, ignore_uninstalled: bool = False, reltivize_paths: bool = True) -> Self: + def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, reltivize_paths: bool = True) -> Self: """Parses a config object from a toml file. Args: file: Path to the file, or a directory containing one called 'config.toml'. - ignore_uninstalled: Whether to raise errors if the specified problem and battle type cannot be found. + ignore_uninstalled: Whether to raise errors if the specified battle type is not installed. reltivize_paths: Wether to relativize paths to the config's location rather than the cwd. """ Battle.load_entrypoints() diff --git a/algobattle/problem.py b/algobattle/problem.py index aeeabb91..067cf878 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -10,6 +10,7 @@ Callable, ClassVar, Literal, + Mapping, ParamSpec, Protocol, Self, @@ -24,6 +25,7 @@ InstanceSolutionModel, Role, Encodable, + import_file_as_module, ) @@ -202,6 +204,12 @@ def default_score( return max(0, min(1, solution.score(instance, Role.solver))) +class DynamicProblemInfo(Protocol): + """Defines the metadadata needed to dynamically import a problem.""" + + location: Path + + class Problem(Generic[InstanceT, SolutionT]): """The definition of a problem.""" @@ -269,10 +277,10 @@ def __init__( self.with_solution = with_solution self.score_function = score_function self.test_instance = test_instance - self._installed[name] = self + self._problems[name] = self __slots__ = ("name", "instance_cls", "solution_cls", "min_size", "with_solution", "score_function", "test_instance") - _installed: "ClassVar[dict[str, AnyProblem]]" = {} + _problems: "ClassVar[dict[str, AnyProblem]]" = {} @overload def score(self, instance: InstanceT, *, solution: SolutionT) -> float: @@ -305,10 +313,25 @@ def score( return self.score_function(instance, solution=solution) @classmethod - def load(cls, name: str) -> "AnyProblem": - """Gets either an installed problem instance using its name or imports an entrypoint.""" - if name in cls._installed: - return cls._installed[name] + def load(cls, name: str, dynamic: Mapping[str, DynamicProblemInfo]) -> "AnyProblem": + """Loads the problem with the given name. + + Args: + name: The name of the Problem to use. + dynamic: Metadata used to dynamically import a problem if needed. + + """ + if name in dynamic: + info = dynamic[name] + existing_problems = cls._problems.copy() + import_file_as_module(info.location, "__algobattle_problem__") + new_problems = {n: p for n, p in cls._problems.items() if n not in existing_problems} + if name not in new_problems: + raise ValueError(f"The {name} problem is not defined in {info.location}") + else: + return cls._problems[name] + if name in cls._problems: + return cls._problems[name] match list(entry_points(group="algobattle.problem", name=name)): case []: raise ValueError("Problem name is not valid.") @@ -328,7 +351,7 @@ def load(cls, name: str) -> "AnyProblem": @classmethod def available(cls) -> set[str]: """Returns the names of all available Problems.""" - return set(chain(cls._installed.keys(), (e.name for e in entry_points(group="algobattle.problem")))) + return set(chain(cls._problems.keys(), (e.name for e in entry_points(group="algobattle.problem")))) AnyProblem = Problem[Any, Any] diff --git a/algobattle/util.py b/algobattle/util.py index 9d6f9fae..25737fa7 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -6,12 +6,15 @@ from dataclasses import dataclass from datetime import datetime from enum import Enum +from importlib.util import module_from_spec, spec_from_file_location from inspect import Parameter, Signature, signature from itertools import chain import json from pathlib import Path +import sys from tempfile import TemporaryDirectory from traceback import format_exception +from types import ModuleType from typing import Any, Callable, ClassVar, Iterable, Literal, LiteralString, TypeVar, Self, cast, get_args from annotated_types import GroupedMetadata @@ -407,3 +410,27 @@ def timestamp() -> str: """Formats the current time into a filename-safe string.""" t = datetime.now() return f"{t.year:04d}-{t.month:02d}-{t.day:02d}_{t.hour:02d}-{t.minute:02d}-{t.second:02d}" + + +def import_file_as_module(path: Path, name: str) -> ModuleType: + """Imports a file as a module. + + Args: + path: A path to a python file. + + Raises: + ValueError: If the path doesn't point to a module or the file cannot be imported properly. + """ + if not path.is_file(): + raise ValueError(f"'{path}' does not point to a python file or a proper parent folder of one.") + + try: + spec = spec_from_file_location(name, path) + assert spec is not None + assert spec.loader is not None + module = module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + except Exception as e: + raise ValueError from e diff --git a/tests/test_match.py b/tests/test_match.py index a0d6d7ac..e2a1a75f 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -211,7 +211,7 @@ def setUpClass(cls) -> None: cls.teams = {"team_0": TeamInfo(generator=cls.problem_path / "generator", solver=cls.problem_path / "solver")} def test_no_cfg_default(self): - with self.assertRaises(ValidationError): + with self.assertRaises(ValueError): AlgobattleConfig.from_file(self.problem_path) def test_empty_cfg(self): From 249ff30162bb0551eecbaa5f951451d7c35a439e Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 07:55:35 +0200 Subject: [PATCH 036/113] fix match config defaults --- algobattle/match.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/algobattle/match.py b/algobattle/match.py index e040a75f..9fbb792c 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -625,9 +625,7 @@ class AlgobattleConfig(BaseModel): """Base that contains all config options and can be parsed from config files.""" # funky defaults to force their validation with context info present - teams: TeamInfos = Field( - default={"team_0": {"generator": Path("generator"), "solver": Path("solver")}}, validate_default=True - ) + teams: TeamInfos = Field(default_factory=dict) execution: ExecutionConfig = Field(default_factory=dict, validate_default=True) match: MatchConfig battle: Battle.Config = Iterated.Config() From 69d35574feb92d32a84e2e8cba099c8b80008443 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 08:03:49 +0200 Subject: [PATCH 037/113] rename project config to algobattle.toml --- algobattle/cli.py | 21 ++++++++------------- algobattle/match.py | 6 +++--- docs/src/{config.toml => algobattle.toml} | 0 docs/tutorial/config.md | 4 ++-- 4 files changed, 13 insertions(+), 18 deletions(-) rename docs/src/{config.toml => algobattle.toml} (100%) diff --git a/algobattle/cli.py b/algobattle/cli.py index a3c73e61..1b0af400 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -203,9 +203,7 @@ def init( with ZipFile(problem) as problem_zip: problem_zip.extractall(unpack_dir) - parsed_config = AlgobattleConfig.from_file( - unpack_dir / "config.toml", reltivize_paths=False - ) + parsed_config = AlgobattleConfig.from_file(unpack_dir, reltivize_paths=False) if target is None: target = Path() / parsed_config.match.problem @@ -227,20 +225,17 @@ def init( path.rename(target / path.name) console.print("Unpacked problem data") else: - parsed_config = AlgobattleConfig.from_file( - target / "config.toml", reltivize_paths=False - ) + parsed_config = AlgobattleConfig.from_file(target, reltivize_paths=False) console.print("Using existing problem data") else: if target is None: target = Path() - if not target.joinpath("config.toml").is_file(): + try: + parsed_config = AlgobattleConfig.from_file(target, reltivize_paths=False) + except ValueError: console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort - parsed_config = AlgobattleConfig.from_file( - target / "config.toml", reltivize_paths=False - ) - console.print("Using existing problem data") + console.print("Using existing project data") problem_name = parsed_config.match.problem if problem_name not in Problem.available(): @@ -274,7 +269,7 @@ def init( console.print(f"{problem_name} problem already is installed") with console.status("Initializing metadata"): - config_doc = parse_toml(target.joinpath("config.toml").read_text()) + config_doc = parse_toml(target.joinpath("algobattle.toml").read_text()) if "teams" not in config_doc: config_doc.add( "teams", @@ -285,7 +280,7 @@ def init( ) if config.default_exec is not None and "execution" not in config_doc: config_doc["execution"] = config.default_exec - (target / "config.toml").write_text(dumps_toml(config_doc)) + target.joinpath("algobattle.toml").write_text(dumps_toml(config_doc)) res_path = parsed_config.execution.results if not res_path.is_absolute(): res_path = target / res_path diff --git a/algobattle/match.py b/algobattle/match.py index 9fbb792c..9e24eb13 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -653,14 +653,14 @@ def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, reltivize_pa """Parses a config object from a toml file. Args: - file: Path to the file, or a directory containing one called 'config.toml'. + file: Path to the file, or a directory containing one called 'algobattle.toml'. ignore_uninstalled: Whether to raise errors if the specified battle type is not installed. reltivize_paths: Wether to relativize paths to the config's location rather than the cwd. """ Battle.load_entrypoints() if not file.is_file(): - if file.joinpath("config.toml").is_file(): - file /= "config.toml" + if file.joinpath("algobattle.toml").is_file(): + file /= "algobattle.toml" else: raise ValueError("The path does not point to an Algobattle project") try: diff --git a/docs/src/config.toml b/docs/src/algobattle.toml similarity index 100% rename from docs/src/config.toml rename to docs/src/algobattle.toml diff --git a/docs/tutorial/config.md b/docs/tutorial/config.md index 383ab124..d2e9141d 100644 --- a/docs/tutorial/config.md +++ b/docs/tutorial/config.md @@ -15,7 +15,7 @@ Running `algobattle --help` will bring up a short description of each of these i `path` : This is the only positional argument. It should either be the path to a config file or one to a directory containing -one that is called `config.toml`. +one that is called `algobattle.toml`. `--silent` / `-s` @@ -43,7 +43,7 @@ An example config file filled with default values looks like this: /// example ```toml -{!> config.toml !} +{!> algobattle.toml !} ``` /// From 311eaa5b1188f46c17cbd04d13a7b4efbf8469dc Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 09:23:03 +0200 Subject: [PATCH 038/113] cleanup dynamic loading process --- algobattle/problem.py | 24 ++++++++++++++++-------- algobattle/util.py | 5 +++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/algobattle/problem.py b/algobattle/problem.py index 067cf878..fafbe40b 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -312,6 +312,17 @@ def score( assert isinstance(self.score_function, ScoreFunctionNoSol) return self.score_function(instance, solution=solution) + @classmethod + def load_file(cls, name: str, file: Path) -> "AnyProblem": + """Loads the problem from the specified file.""" + existing_problems = cls._problems.copy() + import_file_as_module(file, "__algobattle_problem__") + new_problems = {n: p for n, p in cls._problems.items() if n not in existing_problems} + if name not in new_problems: + raise ValueError(f"The {name} problem is not defined in {file}") + else: + return cls._problems[name] + @classmethod def load(cls, name: str, dynamic: Mapping[str, DynamicProblemInfo]) -> "AnyProblem": """Loads the problem with the given name. @@ -320,16 +331,13 @@ def load(cls, name: str, dynamic: Mapping[str, DynamicProblemInfo]) -> "AnyProbl name: The name of the Problem to use. dynamic: Metadata used to dynamically import a problem if needed. + Raises: + ValueError: If the problem is not specified properly + RuntimeError If the problem's dynamic import fails """ if name in dynamic: info = dynamic[name] - existing_problems = cls._problems.copy() - import_file_as_module(info.location, "__algobattle_problem__") - new_problems = {n: p for n, p in cls._problems.items() if n not in existing_problems} - if name not in new_problems: - raise ValueError(f"The {name} problem is not defined in {info.location}") - else: - return cls._problems[name] + return cls.load_file(name, info.location) if name in cls._problems: return cls._problems[name] match list(entry_points(group="algobattle.problem", name=name)): @@ -338,7 +346,7 @@ def load(cls, name: str, dynamic: Mapping[str, DynamicProblemInfo]) -> "AnyProbl case [e]: loaded: object = e.load() if not isinstance(loaded, cls): - raise RuntimeError( + raise ValueError( f"The entrypoint '{name}' doesn't point to a problem but a {loaded.__class__.__qualname__}." ) return loaded diff --git a/algobattle/util.py b/algobattle/util.py index 25737fa7..8c72016e 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -419,7 +419,8 @@ def import_file_as_module(path: Path, name: str) -> ModuleType: path: A path to a python file. Raises: - ValueError: If the path doesn't point to a module or the file cannot be imported properly. + ValueError: If the path doesn't point to a module + RuntimeError: If the file cannot be imported properly """ if not path.is_file(): raise ValueError(f"'{path}' does not point to a python file or a proper parent folder of one.") @@ -433,4 +434,4 @@ def import_file_as_module(path: Path, name: str) -> ModuleType: spec.loader.exec_module(module) return module except Exception as e: - raise ValueError from e + raise RuntimeError from e From b84deb22eb9368a92a05d9e892735988469ed2ae Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 12:17:42 +0200 Subject: [PATCH 039/113] add package command --- algobattle/cli.py | 89 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 1b0af400..fbf6a856 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -15,7 +15,7 @@ from zipfile import ZipFile from anyio import run as run_async_fn -from pydantic import Field +from pydantic import Field, ValidationError from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch from rich.console import Group, RenderableType, Console from rich.live import Live @@ -36,10 +36,11 @@ from rich.columns import Columns from rich.prompt import Prompt, Confirm from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table +from tomlkit.exceptions import ParseError from tomlkit.items import Table as TomlTable from algobattle.battle import Battle -from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig +from algobattle.match import AlgobattleConfig, DynamicProblemConfig, EmptyUi, Match, Ui, ExecutionConfig from algobattle.problem import Instance, Problem from algobattle.program import Generator, Matchup, Solver from algobattle.util import BuildError, EncodableModel, ExceptionInfo, Role, RunningTimer, BaseModel, TempDir, timestamp @@ -414,6 +415,90 @@ def config() -> None: launch(str(CliConfig.path)) +@app.command() +def package( + problem_path: Annotated[ + Optional[Path], Argument(exists=True, help="Path to problem python file or a package containing it.") + ] = None, + config: Annotated[Optional[Path], Option(exists=True, dir_okay=False, help="Path to the config file.")] = None, + description: Annotated[ + Optional[Path], Option(exists=True, dir_okay=False, help="Path to a problem description file.") + ] = None, + out: Annotated[ + Optional[Path], Option("--out", "-o", dir_okay=False, file_okay=False, help="Location of the output.") + ] = None, +) -> None: + """Packages problem data into an `.algo` file.""" + if problem_path is None: + if Path("problem.py").is_file(): + problem_path = Path("problem.py") + elif Path("problem").is_dir(): + problem_path = Path("problem") + else: + console.print("[red]Couldn't find a problem package") + raise Abort + if config is None: + if problem_path.parent.joinpath("algobattle.toml").is_file(): + config = problem_path.parent / "algobattle.toml" + else: + console.log("[red]Couldn't find a config file") + raise Abort + if description is None: + match list(problem_path.parent.resolve().glob("description.*")): + case []: + pass + case [desc]: + description = desc + case _: + console.print( + "[red]Found multiple potential description files[/], explicitly specify which you want to include" + ) + raise Abort + + try: + config_doc = parse_toml(config.read_text()) + parsed_config = AlgobattleConfig.from_file(config) + except (ValidationError, ParseError) as e: + console.print(f"[red]Improperly formatted config file\nError: {e}") + raise Abort + problem_name = parsed_config.match.problem + try: + with console.status("Loading problem"): + Problem.load_file(problem_name, problem_path) + except (ValueError, RuntimeError) as e: + console.print(f"[red]Couldn't load the problem file[/]\nError: {e}") + raise Abort + problem_info = parsed_config.problems[problem_name] + + if "execution" in config_doc: + config_doc.remove("execution") + if "teams" in config_doc: + config_doc.remove("teams") + info_doc = table().append( + "location", + "problem.py" + if problem_path.is_file() + else Path("problem") / problem_info.location.resolve().relative_to(problem_path.resolve()), + ) + if problem_info.dependencies: + info_doc.append("dependencies", problem_info.dependencies) + config_doc["problems"] = table().append(problem_name, info_doc) + + if out is None: + out = problem_path.parent / f"{problem_name.lower().replace(' ', '_')}.algo" + with console.status("Packaging data"), ZipFile(out, "w") as file: + if problem_path.is_file(): + file.write(problem_path, "problem.py") + else: + for path in problem_path.glob("**"): + if path.is_file(): + file.write(path, Path("problem") / path.relative_to(problem_path)) + file.writestr("algobattle.toml", dumps_toml(config_doc)) + if description is not None: + file.write(description, description.name) + console.print("[green]Packaged Algobattle project into[/]", out) + + class TimerTotalColumn(ProgressColumn): """Renders time elapsed.""" From 05329a790c5317919e6f95aa04304eb06818228c Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 16:04:50 +0200 Subject: [PATCH 040/113] update init command --- algobattle/cli.py | 44 ++++++++++++++++---------------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index fbf6a856..7112dfef 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -3,6 +3,7 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ from enum import StrEnum +from functools import cached_property from os import environ from pathlib import Path from random import choice @@ -97,7 +98,8 @@ def default_exec(self) -> TomlTable | None: exec: Any = self._doc.get("execution", None) return exec - def install_cmd(self, target: Path) -> list[str]: + @cached_property + def install_cmd(self) -> list[str]: cmd = [sys.executable, "-m", "pip", "install"] if self.general.install_mode is None: command_str: str = Prompt.ask( @@ -116,7 +118,7 @@ def install_cmd(self, target: Path) -> list[str]: self._doc.add("general", table()) cast(TomlTable, self._doc["general"])["install_mode"] = command_str self.save() - return cmd + [str(target.resolve())] + return cmd @app.command("run") @@ -239,35 +241,21 @@ def init( console.print("Using existing project data") problem_name = parsed_config.match.problem - if problem_name not in Problem.available(): - existing_data = set(p.resolve() for p in target.iterdir()) - cmd = config.install_cmd(target) - try: - with console.status("Installing problem"), Popen( - cmd, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True - ) as installer: - assert installer.stdout is not None - assert installer.stderr is not None - for line in installer.stdout: - console.print(line.strip("\n")) - error = "".join(installer.stderr.readlines()) - # pip leaves behind some build artifacts we want to clean up - finally: - for path in target.iterdir(): - path = path.resolve() - if path in existing_data: - continue - elif path.is_file(): - path.unlink() - elif path.is_dir(): - rmtree(path) + if (info := parsed_config.problems.get(problem_name, None)) and info.dependencies: + cmd = config.install_cmd + with console.status(f"Installing {problem_name}'s dependencies"), Popen( + cmd + info.dependencies, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True + ) as installer: + assert installer.stdout is not None + assert installer.stderr is not None + for line in installer.stdout: + console.print(line.strip("\n")) + error = "".join(installer.stderr.readlines()) if installer.returncode: - console.print(f"[red]Couldn't install the problem[/]\n{error}") + console.print(f"[red]Couldn't install the dependencies[/]\n{error}") raise Abort else: - console.print(f"Installed problem {problem_name}") - else: - console.print(f"{problem_name} problem already is installed") + console.print(f"[green]Installed dependencies of {problem_name}") with console.status("Initializing metadata"): config_doc = parse_toml(target.joinpath("algobattle.toml").read_text()) From 43ecc9c691876636deca9719478b5218e542d984 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 16:16:13 +0200 Subject: [PATCH 041/113] update team names --- algobattle/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 7112dfef..c9da441e 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -198,7 +198,10 @@ def init( if language: generator = solver = language config = CliConfig.load() - team_name = config.general.team_name or choice(("Dogs", "Cats", "Otters", "Red Pandas", "Possums", "Rats")) + team_name = config.general.team_name or choice( + ("Dogs", "Cats", "Otters", "Red Pandas", "Crows", "Rats", "Cockatoos", "Dingos", "Penguins", "Kiwis", "Orcas") + + ("Bearded Dragons", "Macaws", "Wombats", "Wallabies", "Owls", "Seals", "Octopuses", "Frogs", "Jellyfish") + ) if problem is not None: with TempDir() as unpack_dir: From 2e36ad084ea7f13ee4765248ddb9fb1127cacb13 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 16:31:18 +0200 Subject: [PATCH 042/113] fix problem file path validation --- algobattle/cli.py | 5 ++++- algobattle/match.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index c9da441e..ba0b952d 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -244,7 +244,10 @@ def init( console.print("Using existing project data") problem_name = parsed_config.match.problem - if (info := parsed_config.problems.get(problem_name, None)) and info.dependencies: + info = parsed_config.problems.get(problem_name, None) + if info is not None and not info.location.is_absolute(): + info.location = target / info.location + if info is not None and info.dependencies: cmd = config.install_cmd with console.status(f"Installing {problem_name}'s dependencies"), Popen( cmd + info.dependencies, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True diff --git a/algobattle/match.py b/algobattle/match.py index 9e24eb13..09032cae 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -582,7 +582,7 @@ class MatchConfig(BaseModel): class DynamicProblemConfig(BaseModel): """Defines metadata used to dynamically import problems.""" - location: RelativeFilePath = Field(default=Path("problem.py"), validate_default=True) + location: RelativePath = Field(default=Path("problem.py"), validate_default=True) """Path to the file defining the problem""" dependencies: list[str] = Field(default_factory=list) """List of dependencies needed to run the problem""" From bae11e5002f31b4d78221836312db9768a237627 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 16:46:04 +0200 Subject: [PATCH 043/113] cleanup init docs --- algobattle/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index ba0b952d..d9677678 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -177,7 +177,7 @@ def init( ] = None, problem: Annotated[ Optional[Path], - Option("--problem", "-p", exists=True, dir_okay=False, help="A problem spec file to use for this."), + Option("--problem", "-p", exists=True, dir_okay=False, help="The .algo file to use for this."), ] = None, language: Annotated[ Optional[Language], Option("--language", "-l", help="The language to use for the programs.") @@ -187,7 +187,7 @@ def init( ] = None, solver: Annotated[Optional[Language], Option("--solver", "-s", help="The language to use for the solver.")] = None, ) -> None: - """Initializes a project directory, setting up the problem files and program folders with docker files. + """Initializes a project directory, setting up the problem files and program folders. Generates dockerfiles and an initial project structure for the language(s) you choose. Either use `--language` to use the same language for both, or specify each individually with `--generator` and `--solver`. From 7493a472e1eb6222a5aee58e19b9df5eb18b9391 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 16:50:18 +0200 Subject: [PATCH 044/113] better test command error messages --- algobattle/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/algobattle/cli.py b/algobattle/cli.py index d9677678..0317cf76 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -316,6 +316,9 @@ def test( team: Annotated[Optional[str], Option(help="Name of the team whose programs you want to test.")] = None, ) -> None: """Tests whether the programs install successfully and run on dummy instances without crashing.""" + if not (folder.is_file() or folder.joinpath("algobattle.toml").is_file()): + console.print("[red]The folder does not contain an Algobattle project") + raise Abort config = AlgobattleConfig.from_file(folder) problem = config.problem errors = TestErrors() From 4ec6f818301fce8e8c04a2527775e9d434ef945f Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 16:58:12 +0200 Subject: [PATCH 045/113] cleanup imports --- algobattle/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 0317cf76..8b101506 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -41,7 +41,7 @@ from tomlkit.items import Table as TomlTable from algobattle.battle import Battle -from algobattle.match import AlgobattleConfig, DynamicProblemConfig, EmptyUi, Match, Ui, ExecutionConfig +from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig from algobattle.problem import Instance, Problem from algobattle.program import Generator, Matchup, Solver from algobattle.util import BuildError, EncodableModel, ExceptionInfo, Role, RunningTimer, BaseModel, TempDir, timestamp From f11a858739e9042a3e6b5f49a517a6e6bbce58db Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 17:10:40 +0200 Subject: [PATCH 046/113] test all team's programs --- algobattle/cli.py | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 8b101506..b1da9d86 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -4,6 +4,7 @@ """ from enum import StrEnum from functools import cached_property +import json from os import environ from pathlib import Path from random import choice @@ -311,9 +312,6 @@ class TestErrors(BaseModel): @app.command() def test( folder: Annotated[Path, Argument(help="The project folder to use.")] = Path(), - generator: Annotated[bool, Option(help="Whether to test the generator")] = True, - solver: Annotated[bool, Option(help="Whether to test the solver")] = True, - team: Annotated[Optional[str], Option(help="Name of the team whose programs you want to test.")] = None, ) -> None: """Tests whether the programs install successfully and run on dummy instances without crashing.""" if not (folder.is_file() or folder.joinpath("algobattle.toml").is_file()): @@ -321,27 +319,12 @@ def test( raise Abort config = AlgobattleConfig.from_file(folder) problem = config.problem - errors = TestErrors() - if team: - try: - team_info = config.teams[team] - except KeyError: - console.print("[red]The specified team does not exist in the config file.") - raise Abort - else: - match len(config.teams): - case 0: - console.print("[red]The config file contains no teams.") - raise Abort - case 1: - team, team_info = next(iter(config.teams.items())) - case _: - console.print("[red]The config file contains more than one team and none were specified.") - raise Abort + all_errors: dict[str, Any] = {} - console.print(f"Testing {team}'s programs") - instance = None - if generator: + for team, team_info in config.teams.items(): + console.print(f"Testing programs of team {team}") + errors = TestErrors() + instance = None async def gen_builder() -> Generator: with console.status("Building generator"): @@ -365,15 +348,14 @@ async def gen_builder() -> Generator: errors.generator_build = ExceptionInfo.from_exception(e) instance = None - sol_error = None - if solver: + sol_error = None if instance is None: if problem.test_instance is None: console.print( "[magenta2]Cannot test the solver since the generator failed and the problem doesn't provide a test" " instance." ) - raise Exit + continue else: instance = cast(Instance, problem.test_instance) @@ -398,9 +380,12 @@ async def sol_builder() -> Solver: errors.solver_build = ExceptionInfo.from_exception(e) instance = None - if errors != TestErrors(): + if errors != TestErrors(): + all_errors[team] = errors.model_dump(exclude_defaults=True) + + if all_errors: err_path = config.execution.results.joinpath(f"{timestamp()}.json") - err_path.write_text(errors.model_dump_json(indent=4, exclude_defaults=True)) + err_path.write_text(json.dumps(all_errors, indent=4)) console.print(f"You can find detailed error messages at {err_path}") From d027807e679f60d1b1a3f81ec5d2ee14924f2765 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 17:19:08 +0200 Subject: [PATCH 047/113] add --schemas to init command --- algobattle/cli.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index b1da9d86..95928d1e 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -43,7 +43,7 @@ from algobattle.battle import Battle from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig -from algobattle.problem import Instance, Problem +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 from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_templates @@ -187,6 +187,7 @@ def init( Optional[Language], Option("--generator", "-g", help="The language to use for the generator.") ] = None, solver: Annotated[Optional[Language], Option("--solver", "-s", help="The language to use for the solver.")] = None, + schemas: Annotated[bool, Option(help="Whether to also save the problem's IO schemas.")] = False, ) -> None: """Initializes a project directory, setting up the problem files and program folders. @@ -285,6 +286,16 @@ def init( target.joinpath(".gitignore").write_text(f"{res_path.relative_to(target)}/\n") problem_obj = parsed_config.problem + if schemas: + instance: type[Instance] = problem_obj.instance_cls + solution: type[Solution[Instance]] = problem_obj.solution_cls + schema_folder = target / "schemas" + schema_folder.mkdir(exist_ok=True) + if s := instance.io_schema(): + schema_folder.joinpath("instance.json").write_text(s) + if s := solution.io_schema(): + schema_folder.joinpath("solution.json").write_text(s) + template_args: PartialTemplateArgs = { "problem": problem_name, "team": team_name, From efccbdbb4f41fda5f436f13e2c1db7eb21d70d72 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 17:21:09 +0200 Subject: [PATCH 048/113] cleanup python template --- algobattle/templates/python/{{program}}.py.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/templates/python/{{program}}.py.jinja b/algobattle/templates/python/{{program}}.py.jinja index 214915ea..855b7bd4 100644 --- a/algobattle/templates/python/{{program}}.py.jinja +++ b/algobattle/templates/python/{{program}}.py.jinja @@ -1,4 +1,4 @@ -"""Main module, will be run as the program.""" +"""Main module, will be run as the {{ program }}.""" {% if instance_json or solution_json %} import json {% endif %} From 71e0b5d5e1b74188ad57c6d870d78d3489b5fbbd Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 17:32:49 +0200 Subject: [PATCH 049/113] cleanup python template --- algobattle/templates/python/pyproject.toml.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/templates/python/pyproject.toml.jinja b/algobattle/templates/python/pyproject.toml.jinja index ef966bc6..91d524fb 100644 --- a/algobattle/templates/python/pyproject.toml.jinja +++ b/algobattle/templates/python/pyproject.toml.jinja @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "{{ project }}" -version = "0.0.0" +version = "0.0.1" description = "{{program.capitalize()}} for the {{ problem }} problem." requires-python = ">=3.11" authors = [{name = "{{ team }}"}] From abe4f9fdd3fe7a6a442804bddbe0ad033553212f Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 17:37:23 +0200 Subject: [PATCH 050/113] add gitignore to python template --- algobattle/templates/python/.gitignore | 160 +++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 algobattle/templates/python/.gitignore diff --git a/algobattle/templates/python/.gitignore b/algobattle/templates/python/.gitignore new file mode 100644 index 00000000..ca4f2a70 --- /dev/null +++ b/algobattle/templates/python/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file From 8f0a7339a53a75829e7366441bb0f8de096ecdb2 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 18:02:41 +0200 Subject: [PATCH 051/113] simplify template loader structure --- algobattle/templates/__init__.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index fe2bcbbe..95724c0c 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -1,6 +1,7 @@ """Package containing templates used for the algobattle init command.""" from enum import StrEnum +from functools import cached_property from pathlib import Path from typing import Literal from typing_extensions import TypedDict @@ -12,15 +13,15 @@ class Language(StrEnum): python = "python" - -ENVS = { - "python": Environment( - loader=PackageLoader("algobattle.templates", "python"), - keep_trailing_newline=True, - trim_blocks=True, - lstrip_blocks=True, - ) -} + @cached_property + def env(self) -> Environment: + """The jinja environment for this language.""" + return Environment( + loader=PackageLoader("algobattle.templates", self.value), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, + ) class PartialTemplateArgs(TypedDict): @@ -49,9 +50,8 @@ def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: template_args = args | { "project": f"{normalize(args['team'])}-{normalize(args['problem'])}-{normalize(args['program'])}", } - env = ENVS[lang] - for name in env.list_templates(): - template = env.get_template(name) + for name in lang.env.list_templates(): + template = lang.env.get_template(name) formatted = template.render(template_args) formatted_path = Path(Template(name).render(template_args)) if formatted_path.suffix == ".jinja": From 3768877d18718adf3ccbf32b5556d2b90fb890a2 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 18:05:52 +0200 Subject: [PATCH 052/113] make Role a StrEnum --- algobattle/cli.py | 6 +++--- algobattle/templates/__init__.py | 1 + algobattle/util.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 95928d1e..23aa834f 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -153,10 +153,10 @@ def run_match( def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: Role) -> None: - dir = target / role.value + dir = target / role if dir.exists(): replace = Confirm.ask( - f"[magenta2]The targeted directory already contains a {role.value}, do you want to replace it?", + f"[magenta2]The targeted directory already contains a {role}, do you want to replace it?", default=True, ) if replace: @@ -168,7 +168,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: dir.mkdir(parents=True, exist_ok=True) with console.status(f"Initializing {role}"): write_templates(dir, lang, TemplateArgs(program=role.value, **args)) - console.print(f"Created a {lang.value} {role.value} in [cyan]{dir}") + console.print(f"Created a {lang} {role} in [cyan]{dir}") @app.command() diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 95724c0c..8dbec223 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -12,6 +12,7 @@ class Language(StrEnum): """Langues supported by `algobattle init`.""" python = "python" + javascript = "javascript" @cached_property def env(self) -> Environment: diff --git a/algobattle/util.py b/algobattle/util.py index 8c72016e..7be86311 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from enum import Enum +from enum import StrEnum from importlib.util import module_from_spec, spec_from_file_location from inspect import Parameter, Signature, signature from itertools import chain @@ -30,7 +30,7 @@ from pydantic_core.core_schema import general_after_validator_function -class Role(Enum): +class Role(StrEnum): """Indicates whether the role of a program is to generate or to solve instances.""" generator = "generator" From 23058da0c19d920a3234846fc661a05f1605187a Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 18:12:43 +0200 Subject: [PATCH 053/113] add js template --- algobattle/templates/javascript/.gitignore | 130 ++++++++++++++++++ .../templates/javascript/Dockerfile.jinja | 8 ++ .../templates/javascript/package.json.jinja | 8 ++ .../templates/javascript/{{program}}.js.jinja | 37 +++++ 4 files changed, 183 insertions(+) create mode 100644 algobattle/templates/javascript/.gitignore create mode 100644 algobattle/templates/javascript/Dockerfile.jinja create mode 100644 algobattle/templates/javascript/package.json.jinja create mode 100644 algobattle/templates/javascript/{{program}}.js.jinja diff --git a/algobattle/templates/javascript/.gitignore b/algobattle/templates/javascript/.gitignore new file mode 100644 index 00000000..c6bba591 --- /dev/null +++ b/algobattle/templates/javascript/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/algobattle/templates/javascript/Dockerfile.jinja b/algobattle/templates/javascript/Dockerfile.jinja new file mode 100644 index 00000000..0f64f9c9 --- /dev/null +++ b/algobattle/templates/javascript/Dockerfile.jinja @@ -0,0 +1,8 @@ +FROM node + +WORKDIR /algobattle_src +COPY . . +RUN npm install . + +WORKDIR / +CMD [ "node", "/algobattle_src/{{ program }}.js" ] diff --git a/algobattle/templates/javascript/package.json.jinja b/algobattle/templates/javascript/package.json.jinja new file mode 100644 index 00000000..3e69bb82 --- /dev/null +++ b/algobattle/templates/javascript/package.json.jinja @@ -0,0 +1,8 @@ +{ + "name": "{{ project }}", + "version": "0.0.1", + "description": "{{program.capitalize()}} for the {{ problem }} problem.", + "main": "{{ program }}.js", + "author": "{{ team }}", + "license": "ISC" +} diff --git a/algobattle/templates/javascript/{{program}}.js.jinja b/algobattle/templates/javascript/{{program}}.js.jinja new file mode 100644 index 00000000..96e9d176 --- /dev/null +++ b/algobattle/templates/javascript/{{program}}.js.jinja @@ -0,0 +1,37 @@ +// main file, will be run as the {{ program}} +import { readFileSync, writeFileSync } from "fs"; + +{% if program == "generator" %} +const max_size = parseInt(readFileSync("/input/max_size.txt", "utf8")); + + +let instance = null; +{% if with_solution %} +let solution = null; +{% endif %} + + +{% if instance_json %} +writeFileSync("/output/instance.json", JSON.stringify(instance)); +{% else %} +const ouputPath = "/output/instance"; // this is where you need to write the instance to +{% endif %} +{% else %} +{% if instance_json %} +const instance = JSON.parse(readFileSync("/input/instance.json", "utf8")); +{% else %} +const instancePath = "/input/instance"; // this is where you can read the instance from +{% endif %} + + +let solution = null; + + +{% endif %} +{% if program == "solver" or with_solution %} +{% if solution_json %} +writeFileSync("/output/solution.json", JSON.stringify(solution)); +{% else %} +const ouputPath = "/output/solution"; // this is where you need to write the solution to +{% endif %} +{% endif %} From 2de2ed3bfe6a95b5d7802fa1d2acc16325b3d8f2 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 18:25:24 +0200 Subject: [PATCH 054/113] test solver build if generator didnt run --- algobattle/cli.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 23aa834f..32974e50 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -360,15 +360,6 @@ async def gen_builder() -> Generator: instance = None sol_error = None - if instance is None: - if problem.test_instance is None: - console.print( - "[magenta2]Cannot test the solver since the generator failed and the problem doesn't provide a test" - " instance." - ) - continue - else: - instance = cast(Instance, problem.test_instance) async def sol_builder() -> Solver: with console.status("Building solver"): @@ -379,13 +370,18 @@ async def sol_builder() -> Solver: try: with run_async_fn(sol_builder) as sol: console.print("[green]Solver built successfully") - with console.status("Running solver"): - sol_error = sol.test(instance) - if isinstance(sol_error, ExceptionInfo): - console.print("[red]Solver didn't run successfully") - errors.solver_run = sol_error + + instance = instance or cast(Instance, problem.test_instance) + if instance: + with console.status("Running solver"): + sol_error = sol.test(instance) + if isinstance(sol_error, ExceptionInfo): + console.print("[red]Solver didn't run successfully") + errors.solver_run = sol_error + else: + console.print("[green]Solver ran successfully") else: - console.print("[green]Solver ran successfully") + console.print("[magenta2]Cannot test running the solver") except BuildError as e: console.print("[red]Solver didn't build successfully") errors.solver_build = ExceptionInfo.from_exception(e) From c83c5aa5d382e1095044e36aa0ada64865a36f17 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 18:29:35 +0200 Subject: [PATCH 055/113] fix js template --- algobattle/templates/javascript/Dockerfile.jinja | 2 +- algobattle/templates/javascript/package.json.jinja | 3 ++- .../javascript/{{{program}}.js.jinja => {{program}}.mjs.jinja} | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename algobattle/templates/javascript/{{{program}}.js.jinja => {{program}}.mjs.jinja} (100%) diff --git a/algobattle/templates/javascript/Dockerfile.jinja b/algobattle/templates/javascript/Dockerfile.jinja index 0f64f9c9..d16396ee 100644 --- a/algobattle/templates/javascript/Dockerfile.jinja +++ b/algobattle/templates/javascript/Dockerfile.jinja @@ -5,4 +5,4 @@ COPY . . RUN npm install . WORKDIR / -CMD [ "node", "/algobattle_src/{{ program }}.js" ] +CMD [ "node", "/algobattle_src" ] diff --git a/algobattle/templates/javascript/package.json.jinja b/algobattle/templates/javascript/package.json.jinja index 3e69bb82..08380270 100644 --- a/algobattle/templates/javascript/package.json.jinja +++ b/algobattle/templates/javascript/package.json.jinja @@ -2,7 +2,8 @@ "name": "{{ project }}", "version": "0.0.1", "description": "{{program.capitalize()}} for the {{ problem }} problem.", - "main": "{{ program }}.js", + "main": "{{ program }}.mjs", + "type": "module", "author": "{{ team }}", "license": "ISC" } diff --git a/algobattle/templates/javascript/{{program}}.js.jinja b/algobattle/templates/javascript/{{program}}.mjs.jinja similarity index 100% rename from algobattle/templates/javascript/{{program}}.js.jinja rename to algobattle/templates/javascript/{{program}}.mjs.jinja From 0436a0430423f9652ea09f7fce575706bdb764d4 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 19:16:16 +0200 Subject: [PATCH 056/113] use npm ci in js template --- algobattle/templates/javascript/Dockerfile.jinja | 2 +- .../templates/javascript/package-lock.json.jinja | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 algobattle/templates/javascript/package-lock.json.jinja diff --git a/algobattle/templates/javascript/Dockerfile.jinja b/algobattle/templates/javascript/Dockerfile.jinja index d16396ee..95e10561 100644 --- a/algobattle/templates/javascript/Dockerfile.jinja +++ b/algobattle/templates/javascript/Dockerfile.jinja @@ -2,7 +2,7 @@ FROM node WORKDIR /algobattle_src COPY . . -RUN npm install . +RUN npm ci WORKDIR / CMD [ "node", "/algobattle_src" ] diff --git a/algobattle/templates/javascript/package-lock.json.jinja b/algobattle/templates/javascript/package-lock.json.jinja new file mode 100644 index 00000000..a98095af --- /dev/null +++ b/algobattle/templates/javascript/package-lock.json.jinja @@ -0,0 +1,14 @@ +{ + "name": "{{ project }}", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "{{ project }}", + "version": "0.0.1", + "license": "ISC" + } + } + } + \ No newline at end of file From c5e8f3c8df3eac2c7da3cf11a4a4ed762005e06c Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 21:14:10 +0200 Subject: [PATCH 057/113] make test output nicer --- algobattle/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 32974e50..9d909e97 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -381,7 +381,7 @@ async def sol_builder() -> Solver: else: console.print("[green]Solver ran successfully") else: - console.print("[magenta2]Cannot test running the solver") + console.print("[orange3]Cannot test running the solver") except BuildError as e: console.print("[red]Solver didn't build successfully") errors.solver_build = ExceptionInfo.from_exception(e) From 4a261045c60a65cd5e155954b154efd4a660de01 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 21:21:18 +0200 Subject: [PATCH 058/113] add rust template --- algobattle/templates/__init__.py | 1 + algobattle/templates/rust/.gitignore | 1 + algobattle/templates/rust/Cargo.toml.jinja | 12 ++++++ algobattle/templates/rust/Dockerfile.jinja | 8 ++++ algobattle/templates/rust/src/main.rs.jinja | 46 +++++++++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 algobattle/templates/rust/.gitignore create mode 100644 algobattle/templates/rust/Cargo.toml.jinja create mode 100644 algobattle/templates/rust/Dockerfile.jinja create mode 100644 algobattle/templates/rust/src/main.rs.jinja diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 8dbec223..74621076 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -13,6 +13,7 @@ class Language(StrEnum): python = "python" javascript = "javascript" + rust = "rust" @cached_property def env(self) -> Environment: diff --git a/algobattle/templates/rust/.gitignore b/algobattle/templates/rust/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/algobattle/templates/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/algobattle/templates/rust/Cargo.toml.jinja b/algobattle/templates/rust/Cargo.toml.jinja new file mode 100644 index 00000000..672b83e1 --- /dev/null +++ b/algobattle/templates/rust/Cargo.toml.jinja @@ -0,0 +1,12 @@ +[package] +name = "{{ program }}" +version = "0.1.0" +edition = "2021" +authors = ["{{ team }}"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +{% if instance_json or solution_json %} +serde_json = "1.0" +{% endif %} diff --git a/algobattle/templates/rust/Dockerfile.jinja b/algobattle/templates/rust/Dockerfile.jinja new file mode 100644 index 00000000..167390af --- /dev/null +++ b/algobattle/templates/rust/Dockerfile.jinja @@ -0,0 +1,8 @@ +FROM rust + +WORKDIR /algobattle_src +COPY . . +RUN cargo install --path . + +WORKDIR / +CMD [ "{{ program }}" ] diff --git a/algobattle/templates/rust/src/main.rs.jinja b/algobattle/templates/rust/src/main.rs.jinja new file mode 100644 index 00000000..1da32bf9 --- /dev/null +++ b/algobattle/templates/rust/src/main.rs.jinja @@ -0,0 +1,46 @@ +// Main module, will be run as the {{ program }} + +use std::fs; +use std::error::Error; +{% if instance_json or solution_json %} +use serde_json::{ {%- if program == "solver" %}Value, {% endif %}to_string, from_str}; +{% endif %} + + +fn main() -> Result<(), Box> { +{% if program == "generator" %} + let max_size: u64 = fs::read_to_string("/input/max_size.txt")?.parse()?; + + + let instance = (); + {% if with_solution %} + let solution = (); + {% endif %} + + + {% if instance_json %} + fs::write("/output/instance.json", to_string(&instance)?)?; + {% else %} + let instance_output = "/output/instance"; // this is where you need to write the instance to + {% endif %} +{% else %} + {% if instance_json %} + let instance: Value = from_str(&fs::read_to_string("/input/instance.json")?)?; + {% else %} + let instance_input = "/input/instance"; // this is where you can read the instance from + {% endif %} + + + let solution = (); + + +{% endif %} + {% if program == "solver" or with_solution %} + {% if solution_json %} + fs::write("/output/solution.json", to_string(&solution)?)?; + {% else %} + let solution_output = "/output/solution"; // this is where you need to write the solution to + {% endif %} + {% endif %} + Ok(()) +} From 722c65c808666eec89db308eb93e6b2cf7ec5d3b Mon Sep 17 00:00:00 2001 From: Imogen Date: Sat, 16 Sep 2023 21:33:46 +0200 Subject: [PATCH 059/113] add plain template --- algobattle/cli.py | 4 ++++ algobattle/templates/__init__.py | 1 + algobattle/templates/plain/Dockerfile.jinja | 15 +++++++++++++++ 3 files changed, 20 insertions(+) create mode 100644 algobattle/templates/plain/Dockerfile.jinja diff --git a/algobattle/cli.py b/algobattle/cli.py index 9d909e97..4a063a6d 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -305,8 +305,12 @@ def init( } if generator is not None: _init_program(target, generator, template_args, Role.generator) + elif not target.joinpath("generator").exists(): + _init_program(target, Language.plain, template_args, Role.generator) if solver is not None: _init_program(target, solver, template_args, Role.solver) + elif not target.joinpath("solver").exists(): + _init_program(target, Language.plain, template_args, Role.solver) console.print(f"[green]Success![/] initialized algobattle project data in [cyan]{target}[/]") diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 74621076..60a3e23e 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -11,6 +11,7 @@ class Language(StrEnum): """Langues supported by `algobattle init`.""" + plain = "plain" python = "python" javascript = "javascript" rust = "rust" diff --git a/algobattle/templates/plain/Dockerfile.jinja b/algobattle/templates/plain/Dockerfile.jinja new file mode 100644 index 00000000..50c0fa75 --- /dev/null +++ b/algobattle/templates/plain/Dockerfile.jinja @@ -0,0 +1,15 @@ +# the from line specifies a base image to use, most languages publish their own ones with their runtime preinstalled +FROM alpine + +# change the cwd to some new for all following commands +WORKDIR /algobattle_src +# copy our source code into this directory +COPY . . +# compile our program during the build step so we're ready to go when the container runs and won't lose any time +RUN echo "building the {{ program }}" + +# run our program from the root directory +WORKDIR / +# specify the command that will be run once the container is started +# using the '["program", "arg", "as", "list"]' syntax is better here than 'program arg as list' +CMD [ "echo", "running the {{program}}" ] From c067afe8bee85af891d0be39dbef9496710a7a8a Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 02:04:27 +0200 Subject: [PATCH 060/113] use existing team name for init if using exisitng algobattle.toml --- algobattle/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/algobattle/cli.py b/algobattle/cli.py index 4a063a6d..90e40ca2 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -244,6 +244,8 @@ def init( console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort console.print("Using existing project data") + if len(parsed_config.teams) == 1: + team_name = next(iter(parsed_config.teams.keys())) problem_name = parsed_config.match.problem info = parsed_config.problems.get(problem_name, None) From c90d2ffcd6d942f86f9eb2d1299eab0d1dbd794f Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 02:09:36 +0200 Subject: [PATCH 061/113] add java templates --- algobattle/templates/__init__.py | 2 + algobattle/templates/java/.dockerignore | 1 + algobattle/templates/java/.gitignore | 17 +++++++ algobattle/templates/java/Dockerfile.jinja | 8 +++ algobattle/templates/java/pom.xml.jinja | 30 +++++++++++ .../{{program.capitalize()}}.java.jinja | 50 +++++++++++++++++++ 6 files changed, 108 insertions(+) create mode 100644 algobattle/templates/java/.dockerignore create mode 100644 algobattle/templates/java/.gitignore create mode 100644 algobattle/templates/java/Dockerfile.jinja create mode 100644 algobattle/templates/java/pom.xml.jinja create mode 100644 algobattle/templates/java/src/main/java/com/{{team_normalized}}/{{program.capitalize()}}.java.jinja diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 60a3e23e..030bbd10 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -15,6 +15,7 @@ class Language(StrEnum): python = "python" javascript = "javascript" rust = "rust" + java = "java" @cached_property def env(self) -> Environment: @@ -52,6 +53,7 @@ def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: """Writes the formatted templates to the target directory.""" template_args = args | { "project": f"{normalize(args['team'])}-{normalize(args['problem'])}-{normalize(args['program'])}", + "team_normalized": args["team"].lower().replace(" ", "") } for name in lang.env.list_templates(): template = lang.env.get_template(name) diff --git a/algobattle/templates/java/.dockerignore b/algobattle/templates/java/.dockerignore new file mode 100644 index 00000000..2cba5838 --- /dev/null +++ b/algobattle/templates/java/.dockerignore @@ -0,0 +1 @@ +target diff --git a/algobattle/templates/java/.gitignore b/algobattle/templates/java/.gitignore new file mode 100644 index 00000000..f6c024da --- /dev/null +++ b/algobattle/templates/java/.gitignore @@ -0,0 +1,17 @@ +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + +# Eclipse m2e generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath diff --git a/algobattle/templates/java/Dockerfile.jinja b/algobattle/templates/java/Dockerfile.jinja new file mode 100644 index 00000000..bcb36632 --- /dev/null +++ b/algobattle/templates/java/Dockerfile.jinja @@ -0,0 +1,8 @@ +FROM maven:3.9.4-eclipse-temurin-20 + +WORKDIR /algobattle_src +COPY . . +RUN mvn package + +WORKDIR / +CMD ["java", "-jar", "/{{ program }}.jar"] diff --git a/algobattle/templates/java/pom.xml.jinja b/algobattle/templates/java/pom.xml.jinja new file mode 100644 index 00000000..a007b22f --- /dev/null +++ b/algobattle/templates/java/pom.xml.jinja @@ -0,0 +1,30 @@ + + 4.0.0 + + com.{{ team_normalized }} + {{ program }} + {{program.capitalize()}} for the {{ problem }} problem. + + 0.1.0 + jar + + {{ program }} + + + org.apache.maven.plugins + maven-jar-plugin + 3.1.0 + + / + + + true + + com.{{ team_normalized }}.{{ program.capitalize() }} + + + + + + + \ No newline at end of file diff --git a/algobattle/templates/java/src/main/java/com/{{team_normalized}}/{{program.capitalize()}}.java.jinja b/algobattle/templates/java/src/main/java/com/{{team_normalized}}/{{program.capitalize()}}.java.jinja new file mode 100644 index 00000000..6cfd28ce --- /dev/null +++ b/algobattle/templates/java/src/main/java/com/{{team_normalized}}/{{program.capitalize()}}.java.jinja @@ -0,0 +1,50 @@ +// main class, will be executed as the {{ program }} +package com.{{ team_normalized }}; +{% if program == "generator" %} +import java.nio.file.Files; +import java.nio.file.Path; +{% endif %} + + +class {{ program.capitalize() }} { + public static void main(String[] args) { +{% if program == "generator" %} + try { + int max_size = Integer.parseInt(Files.readString(Path.of("/input/max_size.txt"))); + } catch (Exception e) { + return; + } + + + Object instance = null; + {% if with_solution %} + Object solution = null; + {% endif %} + + + {% if instance_json %} + String instance_output = "/output/instance.json"; // this is where you need to write the generated instance to + {% else %} + String instance_output = "/output/instance"; // this is where you need to write the generated instance to + {% endif %} +{% else %} + {% if instance_json %} + String instance_input = "/input/instance.json"; // this is where you can read the input instance from + {% else %} + String instance_input = "/input/instance"; // this is where you can read the input instance from + {% endif %} + + + Object solution = null; + + +{% endif %} +{% if program == "solver" or with_solution %} + {% if solution_json %} + String solution_output = "/output/solution.json"; // this is where you need to write the solution to + {% else %} + String solution_output = "/output/solution"; // this is where you need to write the solution to + {% endif %} +{% endif %} + } +} From e1df190f4970dbe944fe982d433eec975dd66ffd Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 13:32:17 +0200 Subject: [PATCH 062/113] add cpp templates --- algobattle/templates/__init__.py | 1 + algobattle/templates/cpp/.gitignore | 32 +++++++++++++++++++++ algobattle/templates/cpp/Dockerfile | 12 ++++++++ algobattle/templates/cpp/src/CMakeLists.txt | 11 +++++++ algobattle/templates/cpp/src/main.cpp | 6 ++++ 5 files changed, 62 insertions(+) create mode 100644 algobattle/templates/cpp/.gitignore create mode 100644 algobattle/templates/cpp/Dockerfile create mode 100644 algobattle/templates/cpp/src/CMakeLists.txt create mode 100644 algobattle/templates/cpp/src/main.cpp diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 030bbd10..4d587ffe 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -16,6 +16,7 @@ class Language(StrEnum): javascript = "javascript" rust = "rust" java = "java" + cpp = "cpp" @cached_property def env(self) -> Environment: diff --git a/algobattle/templates/cpp/.gitignore b/algobattle/templates/cpp/.gitignore new file mode 100644 index 00000000..a6420950 --- /dev/null +++ b/algobattle/templates/cpp/.gitignore @@ -0,0 +1,32 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app diff --git a/algobattle/templates/cpp/Dockerfile b/algobattle/templates/cpp/Dockerfile new file mode 100644 index 00000000..ea570b76 --- /dev/null +++ b/algobattle/templates/cpp/Dockerfile @@ -0,0 +1,12 @@ +FROM debian:12 + +RUN apt update && apt upgrade +RUN apt install -y clang-15 cmake ninja-build + +WORKDIR /algobattle_src +COPY . . +ENV CC=/usr/bin/clang-15 CXX=/usr/bin/clang++-15 +RUN cmake -S src -B build && cmake --build build --target install + +WORKDIR / +CMD ["main"] diff --git a/algobattle/templates/cpp/src/CMakeLists.txt b/algobattle/templates/cpp/src/CMakeLists.txt new file mode 100644 index 00000000..998bf4e0 --- /dev/null +++ b/algobattle/templates/cpp/src/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.1...3.27) + +project( + ModernCMakeExample + VERSION 1.0 + LANGUAGES CXX +) + +add_executable(main main.cpp) + +install(TARGETS main DESTINATION "") diff --git a/algobattle/templates/cpp/src/main.cpp b/algobattle/templates/cpp/src/main.cpp new file mode 100644 index 00000000..52a91534 --- /dev/null +++ b/algobattle/templates/cpp/src/main.cpp @@ -0,0 +1,6 @@ +#include + +int main() { + std::cout << "hello world\n"; + return 0; +} From b8b560621d67ad559ea39e1af8824b40b684fe42 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 13:36:00 +0200 Subject: [PATCH 063/113] add C template --- algobattle/templates/__init__.py | 1 + algobattle/templates/c/.gitignore | 32 +++++++++++++++++++++++ algobattle/templates/c/Dockerfile | 12 +++++++++ algobattle/templates/c/src/CMakeLists.txt | 11 ++++++++ algobattle/templates/c/src/main.c | 5 ++++ 5 files changed, 61 insertions(+) create mode 100644 algobattle/templates/c/.gitignore create mode 100644 algobattle/templates/c/Dockerfile create mode 100644 algobattle/templates/c/src/CMakeLists.txt create mode 100644 algobattle/templates/c/src/main.c diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 4d587ffe..c073ecbb 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -17,6 +17,7 @@ class Language(StrEnum): rust = "rust" java = "java" cpp = "cpp" + c = "c" @cached_property def env(self) -> Environment: diff --git a/algobattle/templates/c/.gitignore b/algobattle/templates/c/.gitignore new file mode 100644 index 00000000..a6420950 --- /dev/null +++ b/algobattle/templates/c/.gitignore @@ -0,0 +1,32 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app diff --git a/algobattle/templates/c/Dockerfile b/algobattle/templates/c/Dockerfile new file mode 100644 index 00000000..ea570b76 --- /dev/null +++ b/algobattle/templates/c/Dockerfile @@ -0,0 +1,12 @@ +FROM debian:12 + +RUN apt update && apt upgrade +RUN apt install -y clang-15 cmake ninja-build + +WORKDIR /algobattle_src +COPY . . +ENV CC=/usr/bin/clang-15 CXX=/usr/bin/clang++-15 +RUN cmake -S src -B build && cmake --build build --target install + +WORKDIR / +CMD ["main"] diff --git a/algobattle/templates/c/src/CMakeLists.txt b/algobattle/templates/c/src/CMakeLists.txt new file mode 100644 index 00000000..fc59da63 --- /dev/null +++ b/algobattle/templates/c/src/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.1...3.27) + +project( + ModernCMakeExample + VERSION 1.0 + LANGUAGES C +) + +add_executable(main main.c) + +install(TARGETS main DESTINATION "") diff --git a/algobattle/templates/c/src/main.c b/algobattle/templates/c/src/main.c new file mode 100644 index 00000000..212b4d87 --- /dev/null +++ b/algobattle/templates/c/src/main.c @@ -0,0 +1,5 @@ + +int main() { + printf("hello world\n"); + return 0; +} From 54e3b0391828cc356e2b3ea5f84891bb6df7bf30 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 19:56:38 +0200 Subject: [PATCH 064/113] add csharp templates --- algobattle/templates/__init__.py | 1 + algobattle/templates/csharp/Dockerfile.jinja | 8 ++++++++ algobattle/templates/csharp/Program.cs | 3 +++ .../templates/csharp/{{program.capitalize()}}.csproj | 10 ++++++++++ 4 files changed, 22 insertions(+) create mode 100644 algobattle/templates/csharp/Dockerfile.jinja create mode 100644 algobattle/templates/csharp/Program.cs create mode 100644 algobattle/templates/csharp/{{program.capitalize()}}.csproj diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index c073ecbb..e7a46708 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -18,6 +18,7 @@ class Language(StrEnum): java = "java" cpp = "cpp" c = "c" + csharp = "csharp" @cached_property def env(self) -> Environment: diff --git a/algobattle/templates/csharp/Dockerfile.jinja b/algobattle/templates/csharp/Dockerfile.jinja new file mode 100644 index 00000000..1369f508 --- /dev/null +++ b/algobattle/templates/csharp/Dockerfile.jinja @@ -0,0 +1,8 @@ +FROM mcr.microsoft.com/dotnet/sdk:7.0 + +WORKDIR /algobattle +COPY . . +RUN dotnet publish -c Release -o /algobattle/out + +WORKDIR / +CMD ["dotnet", "/algobattle/out/{{program.capitalize()}}.dll"] diff --git a/algobattle/templates/csharp/Program.cs b/algobattle/templates/csharp/Program.cs new file mode 100644 index 00000000..bccd7f06 --- /dev/null +++ b/algobattle/templates/csharp/Program.cs @@ -0,0 +1,3 @@ +// main file of the program, will be run when the container starts + +Console.WriteLine("Hello, World!"); diff --git a/algobattle/templates/csharp/{{program.capitalize()}}.csproj b/algobattle/templates/csharp/{{program.capitalize()}}.csproj new file mode 100644 index 00000000..d4398000 --- /dev/null +++ b/algobattle/templates/csharp/{{program.capitalize()}}.csproj @@ -0,0 +1,10 @@ + + + + Exe + net7.0 + enable + enable + + + From 6cfd1664f31bbd06881a277b02b3eed0ad487d26 Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 20:32:48 +0200 Subject: [PATCH 065/113] add go template --- algobattle/templates/__init__.py | 1 + algobattle/templates/go/.gitignore | 21 +++++++++++++++++++++ algobattle/templates/go/Dockerfile | 11 +++++++++++ algobattle/templates/go/go.mod.jinja | 3 +++ algobattle/templates/go/main.go | 7 +++++++ 5 files changed, 43 insertions(+) create mode 100644 algobattle/templates/go/.gitignore create mode 100644 algobattle/templates/go/Dockerfile create mode 100644 algobattle/templates/go/go.mod.jinja create mode 100644 algobattle/templates/go/main.go diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index e7a46708..791c1c14 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -19,6 +19,7 @@ class Language(StrEnum): cpp = "cpp" c = "c" csharp = "csharp" + go = "go" @cached_property def env(self) -> Environment: diff --git a/algobattle/templates/go/.gitignore b/algobattle/templates/go/.gitignore new file mode 100644 index 00000000..6c66a079 --- /dev/null +++ b/algobattle/templates/go/.gitignore @@ -0,0 +1,21 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work diff --git a/algobattle/templates/go/Dockerfile b/algobattle/templates/go/Dockerfile new file mode 100644 index 00000000..bfcfc51d --- /dev/null +++ b/algobattle/templates/go/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.21 + +WORKDIR /algobattle + +COPY go.mod *.sum ./ +RUN go mod download + +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o out/ + +CMD ["/algobattle/out/{{ program }}"] diff --git a/algobattle/templates/go/go.mod.jinja b/algobattle/templates/go/go.mod.jinja new file mode 100644 index 00000000..34e800ef --- /dev/null +++ b/algobattle/templates/go/go.mod.jinja @@ -0,0 +1,3 @@ +module {{ program }} + +go 1.21 diff --git a/algobattle/templates/go/main.go b/algobattle/templates/go/main.go new file mode 100644 index 00000000..47125b9f --- /dev/null +++ b/algobattle/templates/go/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("hello world") +} From ab10fec460bdb23783796720e3980ca44ed6122f Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 21:53:59 +0200 Subject: [PATCH 066/113] add typescript template --- algobattle/templates/__init__.py | 1 + algobattle/templates/typescript/.gitignore | 41 +++++++++++++++++++ algobattle/templates/typescript/Dockerfile | 12 ++++++ .../typescript/package-lock.json.jinja | 35 ++++++++++++++++ .../templates/typescript/package.json.jinja | 15 +++++++ algobattle/templates/typescript/src/main.ts | 2 + algobattle/templates/typescript/tsconfig.json | 16 ++++++++ 7 files changed, 122 insertions(+) create mode 100644 algobattle/templates/typescript/.gitignore create mode 100644 algobattle/templates/typescript/Dockerfile create mode 100644 algobattle/templates/typescript/package-lock.json.jinja create mode 100644 algobattle/templates/typescript/package.json.jinja create mode 100644 algobattle/templates/typescript/src/main.ts create mode 100644 algobattle/templates/typescript/tsconfig.json diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 791c1c14..cfd0b6b7 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -14,6 +14,7 @@ class Language(StrEnum): plain = "plain" python = "python" javascript = "javascript" + typescript = "typescript" rust = "rust" java = "java" cpp = "cpp" diff --git a/algobattle/templates/typescript/.gitignore b/algobattle/templates/typescript/.gitignore new file mode 100644 index 00000000..d8697235 --- /dev/null +++ b/algobattle/templates/typescript/.gitignore @@ -0,0 +1,41 @@ +lib-cov +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.gz +*.swp + +pids +logs +results +tmp + +# Build +public/css/main.css + +# Coverage reports +coverage + +# API keys and secrets +.env + +# Dependency directory +node_modules +bower_components + +# Editors +.idea +*.iml + +# OS metadata +.DS_Store +Thumbs.db + +# Ignore built ts files +dist/**/* + +# ignore yarn.lock +yarn.lock diff --git a/algobattle/templates/typescript/Dockerfile b/algobattle/templates/typescript/Dockerfile new file mode 100644 index 00000000..3dc0a763 --- /dev/null +++ b/algobattle/templates/typescript/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20 + +WORKDIR /algobattle +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src src/ +RUN npm run build + +WORKDIR / +CMD [ "npm", "run", "--prefix", "/algobattle", "start" ] diff --git a/algobattle/templates/typescript/package-lock.json.jinja b/algobattle/templates/typescript/package-lock.json.jinja new file mode 100644 index 00000000..779fd934 --- /dev/null +++ b/algobattle/templates/typescript/package-lock.json.jinja @@ -0,0 +1,35 @@ +{ + "name": "{{ program }}", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "{{ program }}", + "version": "0.0.1", + "license": "ISC", + "devDependencies": { + "typescript": "^5.2.2" + } + }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@types/node": { + "version": "20.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.2.tgz", + "integrity": "sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw==", + "dev": true + } + } +} diff --git a/algobattle/templates/typescript/package.json.jinja b/algobattle/templates/typescript/package.json.jinja new file mode 100644 index 00000000..a345fe68 --- /dev/null +++ b/algobattle/templates/typescript/package.json.jinja @@ -0,0 +1,15 @@ +{ + "name": "{{ project }}", + "version": "0.0.1", + "description": "{{program.capitalize()}} for the {{ problem }} problem.", + "author": "{{ team }}", + "license": "ISC", + "devDependencies": { + "typescript": "^5.2.2", + "@types/node": "^20.6.2" + }, + "scripts": { + "start": "NODE_PATH=./build node build/main.js", + "build": "tsc -p ." + } +} diff --git a/algobattle/templates/typescript/src/main.ts b/algobattle/templates/typescript/src/main.ts new file mode 100644 index 00000000..56c725e1 --- /dev/null +++ b/algobattle/templates/typescript/src/main.ts @@ -0,0 +1,2 @@ + +console.log("hello world"); diff --git a/algobattle/templates/typescript/tsconfig.json b/algobattle/templates/typescript/tsconfig.json new file mode 100644 index 00000000..bfe5089d --- /dev/null +++ b/algobattle/templates/typescript/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "baseUrl": "./src", + "outDir": "./build", + + "lib": ["es2023"], + "module": "node16", + "target": "es2022", + + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node16" + } +} From da6c422a401e2eeda7ed02e372bc2464e537972f Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 22:06:55 +0200 Subject: [PATCH 067/113] improve rust template --- algobattle/templates/rust/.gitignore | 11 ++++++++++- algobattle/templates/rust/Dockerfile.jinja | 5 +++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/algobattle/templates/rust/.gitignore b/algobattle/templates/rust/.gitignore index ea8c4bf7..73fab072 100644 --- a/algobattle/templates/rust/.gitignore +++ b/algobattle/templates/rust/.gitignore @@ -1 +1,10 @@ -/target +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/algobattle/templates/rust/Dockerfile.jinja b/algobattle/templates/rust/Dockerfile.jinja index 167390af..b018c96e 100644 --- a/algobattle/templates/rust/Dockerfile.jinja +++ b/algobattle/templates/rust/Dockerfile.jinja @@ -1,7 +1,8 @@ FROM rust -WORKDIR /algobattle_src -COPY . . +WORKDIR /algobattle +COPY Cargo.toml ./ +COPY src src/ RUN cargo install --path . WORKDIR / From e6d3b17c5fac05858a935f85101e1c6bcd171cbf Mon Sep 17 00:00:00 2001 From: Imogen Date: Sun, 17 Sep 2023 22:21:51 +0200 Subject: [PATCH 068/113] cleanup templates --- algobattle/templates/c/Dockerfile | 4 ++-- algobattle/templates/cpp/Dockerfile | 4 ++-- algobattle/templates/csharp/Dockerfile.jinja | 2 +- algobattle/templates/go/Dockerfile | 2 +- algobattle/templates/java/Dockerfile.jinja | 4 ++-- algobattle/templates/javascript/Dockerfile.jinja | 6 +++--- algobattle/templates/javascript/package-lock.json.jinja | 2 +- algobattle/templates/javascript/package.json.jinja | 2 +- algobattle/templates/plain/Dockerfile.jinja | 7 ++++--- algobattle/templates/python/Dockerfile.jinja | 4 ++-- algobattle/templates/python/pyproject.toml.jinja | 2 +- 11 files changed, 20 insertions(+), 19 deletions(-) diff --git a/algobattle/templates/c/Dockerfile b/algobattle/templates/c/Dockerfile index ea570b76..392d7a06 100644 --- a/algobattle/templates/c/Dockerfile +++ b/algobattle/templates/c/Dockerfile @@ -3,8 +3,8 @@ FROM debian:12 RUN apt update && apt upgrade RUN apt install -y clang-15 cmake ninja-build -WORKDIR /algobattle_src -COPY . . +WORKDIR /algobattle +COPY src src/ ENV CC=/usr/bin/clang-15 CXX=/usr/bin/clang++-15 RUN cmake -S src -B build && cmake --build build --target install diff --git a/algobattle/templates/cpp/Dockerfile b/algobattle/templates/cpp/Dockerfile index ea570b76..392d7a06 100644 --- a/algobattle/templates/cpp/Dockerfile +++ b/algobattle/templates/cpp/Dockerfile @@ -3,8 +3,8 @@ FROM debian:12 RUN apt update && apt upgrade RUN apt install -y clang-15 cmake ninja-build -WORKDIR /algobattle_src -COPY . . +WORKDIR /algobattle +COPY src src/ ENV CC=/usr/bin/clang-15 CXX=/usr/bin/clang++-15 RUN cmake -S src -B build && cmake --build build --target install diff --git a/algobattle/templates/csharp/Dockerfile.jinja b/algobattle/templates/csharp/Dockerfile.jinja index 1369f508..fb94bc1f 100644 --- a/algobattle/templates/csharp/Dockerfile.jinja +++ b/algobattle/templates/csharp/Dockerfile.jinja @@ -1,7 +1,7 @@ FROM mcr.microsoft.com/dotnet/sdk:7.0 WORKDIR /algobattle -COPY . . +COPY . ./ RUN dotnet publish -c Release -o /algobattle/out WORKDIR / diff --git a/algobattle/templates/go/Dockerfile b/algobattle/templates/go/Dockerfile index bfcfc51d..add52bb6 100644 --- a/algobattle/templates/go/Dockerfile +++ b/algobattle/templates/go/Dockerfile @@ -1,11 +1,11 @@ FROM golang:1.21 WORKDIR /algobattle - COPY go.mod *.sum ./ RUN go mod download COPY *.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -o out/ +WORKDIR / CMD ["/algobattle/out/{{ program }}"] diff --git a/algobattle/templates/java/Dockerfile.jinja b/algobattle/templates/java/Dockerfile.jinja index bcb36632..aa93b45f 100644 --- a/algobattle/templates/java/Dockerfile.jinja +++ b/algobattle/templates/java/Dockerfile.jinja @@ -1,7 +1,7 @@ FROM maven:3.9.4-eclipse-temurin-20 -WORKDIR /algobattle_src -COPY . . +WORKDIR /algobattle +COPY . ./ RUN mvn package WORKDIR / diff --git a/algobattle/templates/javascript/Dockerfile.jinja b/algobattle/templates/javascript/Dockerfile.jinja index 95e10561..3742eb4c 100644 --- a/algobattle/templates/javascript/Dockerfile.jinja +++ b/algobattle/templates/javascript/Dockerfile.jinja @@ -1,8 +1,8 @@ FROM node -WORKDIR /algobattle_src -COPY . . +WORKDIR /algobattle +COPY . ./ RUN npm ci WORKDIR / -CMD [ "node", "/algobattle_src" ] +CMD [ "node", "/algobattle" ] diff --git a/algobattle/templates/javascript/package-lock.json.jinja b/algobattle/templates/javascript/package-lock.json.jinja index a98095af..e9c9bea8 100644 --- a/algobattle/templates/javascript/package-lock.json.jinja +++ b/algobattle/templates/javascript/package-lock.json.jinja @@ -1,6 +1,6 @@ { "name": "{{ project }}", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/algobattle/templates/javascript/package.json.jinja b/algobattle/templates/javascript/package.json.jinja index 08380270..0ac2e48a 100644 --- a/algobattle/templates/javascript/package.json.jinja +++ b/algobattle/templates/javascript/package.json.jinja @@ -1,6 +1,6 @@ { "name": "{{ project }}", - "version": "0.0.1", + "version": "0.1.0", "description": "{{program.capitalize()}} for the {{ problem }} problem.", "main": "{{ program }}.mjs", "type": "module", diff --git a/algobattle/templates/plain/Dockerfile.jinja b/algobattle/templates/plain/Dockerfile.jinja index 50c0fa75..8ed6628d 100644 --- a/algobattle/templates/plain/Dockerfile.jinja +++ b/algobattle/templates/plain/Dockerfile.jinja @@ -1,10 +1,11 @@ # the from line specifies a base image to use, most languages publish their own ones with their runtime preinstalled FROM alpine -# change the cwd to some new for all following commands -WORKDIR /algobattle_src +# change the cwd to some new directory for all following commands +WORKDIR /algobattle # copy our source code into this directory -COPY . . +# note the trailing slash, docker needs it to know that we're trying to copy to a directory and not a file +COPY . ./ # compile our program during the build step so we're ready to go when the container runs and won't lose any time RUN echo "building the {{ program }}" diff --git a/algobattle/templates/python/Dockerfile.jinja b/algobattle/templates/python/Dockerfile.jinja index 1b782180..e4748d71 100644 --- a/algobattle/templates/python/Dockerfile.jinja +++ b/algobattle/templates/python/Dockerfile.jinja @@ -1,7 +1,7 @@ FROM python:3.11 -WORKDIR /algobattle_src -COPY . . +WORKDIR /algobattle +COPY . ./ RUN pip install . WORKDIR / diff --git a/algobattle/templates/python/pyproject.toml.jinja b/algobattle/templates/python/pyproject.toml.jinja index 91d524fb..06576cc4 100644 --- a/algobattle/templates/python/pyproject.toml.jinja +++ b/algobattle/templates/python/pyproject.toml.jinja @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "{{ project }}" -version = "0.0.1" +version = "0.1.0" description = "{{program.capitalize()}} for the {{ problem }} problem." requires-python = ">=3.11" authors = [{name = "{{ team }}"}] From a26d222b633f40b0b4531e89d859de7a5086b4e7 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 00:51:55 +0200 Subject: [PATCH 069/113] move battle config into match table --- algobattle/match.py | 9 ++++++--- tests/configs/test.toml | 2 +- tests/test_match.py | 18 +++++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/algobattle/match.py b/algobattle/match.py index 09032cae..1e00d157 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -65,7 +65,7 @@ async def _run_battle( try: await battle.run_battle( handler, - config.battle, + config.match.battle, problem.min_size, battle_ui, ) @@ -96,7 +96,7 @@ async def run( with await TeamHandler.build(config.teams, problem, config.as_prog_config(), ui) as teams: self.active_teams = [t.name for t in teams.active] self.excluded_teams = teams.excluded - battle_cls = Battle.all()[config.battle.type] + battle_cls = Battle.all()[config.match.battle.type] limiter = CapacityLimiter(config.execution.parallel_battles) current_default_thread_limiter().total_tokens = config.execution.parallel_battles set_cpus = config.execution.set_cpus @@ -574,7 +574,11 @@ class MatchConfig(BaseModel): strict_timeouts: bool = False """Whether to raise an error if a program runs into the timeout.""" generator: RunConfig = RunConfig() + """Settings determining generator execution.""" solver: RunConfig = RunConfig() + """Settings determining solver execution.""" + battle: Battle.Config = Iterated.Config() + """Config for the battle type.""" model_config = ConfigDict(revalidate_instances="always") @@ -628,7 +632,6 @@ class AlgobattleConfig(BaseModel): teams: TeamInfos = Field(default_factory=dict) execution: ExecutionConfig = Field(default_factory=dict, validate_default=True) match: MatchConfig - battle: Battle.Config = Iterated.Config() docker: DockerConfig = DockerConfig() problems: dict[str, DynamicProblemConfig] = Field(default_factory=dict) diff --git a/tests/configs/test.toml b/tests/configs/test.toml index 3c2dc2fe..15e0c6ce 100644 --- a/tests/configs/test.toml +++ b/tests/configs/test.toml @@ -4,7 +4,7 @@ problem = "Test Problem" [match.generator] space = 10 -[battle] +[match.battle] type = "Averaged" num_fights = 1 diff --git a/tests/test_match.py b/tests/test_match.py index e2a1a75f..b4246ac8 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -154,12 +154,20 @@ def setUpClass(cls) -> None: cls.problem = TestProblem run_params = RunConfig(timeout=2) cls.config_iter = AlgobattleConfig( - match=MatchConfig(generator=run_params, solver=run_params, problem="Test Problem"), - battle=Iterated.Config(maximum_size=10, rounds=2), + match=MatchConfig( + generator=run_params, + solver=run_params, + problem="Test Problem", + battle=Iterated.Config(maximum_size=10, rounds=2), + ), ) cls.config_avg = AlgobattleConfig( - match=MatchConfig(generator=run_params, solver=run_params, problem="Test Problem"), - battle=Averaged.Config(instance_size=5, num_fights=3), + match=MatchConfig( + generator=run_params, + solver=run_params, + problem="Test Problem", + battle=Averaged.Config(instance_size=5, num_fights=3), + ), ) cls.generator = problem_path / "generator" cls.solver = problem_path / "solver" @@ -226,8 +234,8 @@ def test_cfg(self): match=MatchConfig( generator=RunConfig(space=ByteSize(10)), problem="Test Problem", + battle=Averaged.Config(num_fights=1), ), - battle=Averaged.Config(num_fights=1), execution=ExecutionConfig(points=10, results=self.configs_path / "results"), ), ) From a2d12e42431105341bb855ce70e9fe54f6454634 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 01:24:30 +0200 Subject: [PATCH 070/113] simplify config names --- algobattle/match.py | 8 ++++---- algobattle/program.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/algobattle/match.py b/algobattle/match.py index 1e00d157..e752be5e 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -569,7 +569,7 @@ class MatchConfig(BaseModel): """The problem this match is over.""" build_timeout: WithNone[TimeDeltaFloat] = 600 """Timeout for building each docker image.""" - image_size: WithNone[ByteSizeInt] = None + max_program_size: WithNone[ByteSizeInt] = 4_000_000_000 """Maximum size a built program image is allowed to be.""" strict_timeouts: bool = False """Whether to raise an error if a program runs into the timeout.""" @@ -586,7 +586,7 @@ class MatchConfig(BaseModel): class DynamicProblemConfig(BaseModel): """Defines metadata used to dynamically import problems.""" - location: RelativePath = Field(default=Path("problem.py"), validate_default=True) + location: RelativePath """Path to the file defining the problem""" dependencies: list[str] = Field(default_factory=list) """List of dependencies needed to run the problem""" @@ -600,7 +600,7 @@ class ExecutionConfig(BaseModel): mode: MatchMode = "testing" """Mode of the match.""" set_cpus: str | list[str] | None = None - """Wich cpus to run programs on, if a list is specified each battle will use a different cpu specification in it.""" + """Wich cpus to run programs on, if it is a list each battle will use a different cpu specification for it.""" points: int = 100 """Highest number of points each team can achieve.""" results: RelativePath = Field(default=Path("./results"), validate_default=True) @@ -679,7 +679,7 @@ def as_prog_config(self) -> ProgramConfigView: """Builds a simple object containing all program relevant settings.""" return ProgramConfigView( build_timeout=self.match.build_timeout, - max_image_size=self.match.image_size, + max_program_size=self.match.max_program_size, strict_timeouts=self.match.strict_timeouts, build_kwargs=self.docker.build.kwargs, run_kwargs=self.docker.run.kwargs, diff --git a/algobattle/program.py b/algobattle/program.py index cb6bcb56..b2221f97 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -96,7 +96,7 @@ class ProgramConfigView: """Config settings relevant to the program module.""" build_timeout: float | None - max_image_size: int | None + max_program_size: int | None strict_timeouts: bool build_kwargs: dict[str, Any] run_kwargs: dict[str, Any] @@ -305,12 +305,12 @@ async def build( config=config, ) used_size = cast(dict[str, Any], image.attrs).get("Size", 0) - if config.max_image_size is not None and used_size > config.max_image_size: + if config.max_program_size is not None and used_size > config.max_program_size: try: self.remove() finally: raise BuildError( - "Built image is too large.", detail=f"Size: {used_size}B, limit: {config.max_image_size}B." + "Built image is too large.", detail=f"Size: {used_size}B, limit: {config.max_program_size}B." ) return self From 0c5968b4938057bbdd156736a38e43cc31fb7d7a Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 01:40:42 +0200 Subject: [PATCH 071/113] rename execution table to project --- algobattle/cli.py | 30 ++++++++++++++++-------------- algobattle/match.py | 18 +++++++++--------- tests/configs/test.toml | 2 +- tests/test_match.py | 6 +++--- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 90e40ca2..670f6b58 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -17,7 +17,7 @@ from zipfile import ZipFile from anyio import run as run_async_fn -from pydantic import Field, ValidationError +from pydantic import ConfigDict, Field, ValidationError from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch from rich.console import Group, RenderableType, Console from rich.live import Live @@ -42,7 +42,7 @@ from tomlkit.items import Table as TomlTable from algobattle.battle import Battle -from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ExecutionConfig +from algobattle.match import AlgobattleConfig, EmptyUi, Match, 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 @@ -68,7 +68,7 @@ class _General(BaseModel): class CliConfig(BaseModel): general: _General = Field(default_factory=dict, validate_default=True) - execution: ExecutionConfig = Field(default_factory=dict, validate_default=True) + default_project_config: ProjectConfig | None = Field(default=None) _doc: TOMLDocument path: ClassVar[Path] = Path(get_app_dir("algobattle")) / "config.toml" @@ -94,9 +94,11 @@ def save(self) -> None: self.path.write_text(dumps_toml(self._doc)) @property - def default_exec(self) -> TomlTable | None: + def default_project_doc(self) -> TomlTable | None: """The default exec config for each problem.""" - exec: Any = self._doc.get("execution", None) + exec: Any = self._doc.get( + "default_project_config", table().append("results", "./results").append("mode", "testing") + ) return exec @cached_property @@ -139,14 +141,14 @@ def run_match( finally: try: console.print(CliUi.display_match(result)) - if config.execution.points > 0: - points = result.calculate_points(config.execution.points) + if config.project.points > 0: + points = result.calculate_points(config.project.points) for team, pts in points.items(): print(f"Team {team} gained {pts:.1f} points.") if save: res_string = result.model_dump_json(exclude_defaults=True) - config.execution.results.joinpath(f"{timestamp()}.json").write_text(res_string) + config.project.results.joinpath(f"{timestamp()}.json").write_text(res_string) return result except KeyboardInterrupt: raise Exit @@ -277,10 +279,10 @@ def init( table().add("generator", "./generator").add("solver", "./solver"), ), ) - if config.default_exec is not None and "execution" not in config_doc: - config_doc["execution"] = config.default_exec + if config.default_project_doc is not None and "project" not in config_doc: + config_doc["project"] = config.default_project_doc target.joinpath("algobattle.toml").write_text(dumps_toml(config_doc)) - res_path = parsed_config.execution.results + res_path = parsed_config.project.results if not res_path.is_absolute(): res_path = target / res_path res_path.mkdir(parents=True, exist_ok=True) @@ -397,7 +399,7 @@ async def sol_builder() -> Solver: all_errors[team] = errors.model_dump(exclude_defaults=True) if all_errors: - err_path = config.execution.results.joinpath(f"{timestamp()}.json") + err_path = config.project.results.joinpath(f"{timestamp()}.json") err_path.write_text(json.dumps(all_errors, indent=4)) console.print(f"You can find detailed error messages at {err_path}") @@ -465,8 +467,8 @@ def package( raise Abort problem_info = parsed_config.problems[problem_name] - if "execution" in config_doc: - config_doc.remove("execution") + if "project" in config_doc: + config_doc.remove("project") if "teams" in config_doc: config_doc.remove("teams") info_doc = table().append( diff --git a/algobattle/match.py b/algobattle/match.py index e752be5e..73f7b2e9 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -97,13 +97,13 @@ async def run( self.active_teams = [t.name for t in teams.active] self.excluded_teams = teams.excluded battle_cls = Battle.all()[config.match.battle.type] - limiter = CapacityLimiter(config.execution.parallel_battles) - current_default_thread_limiter().total_tokens = config.execution.parallel_battles - set_cpus = config.execution.set_cpus + limiter = CapacityLimiter(config.project.parallel_battles) + current_default_thread_limiter().total_tokens = config.project.parallel_battles + set_cpus = config.project.set_cpus if isinstance(set_cpus, list): - match_cpus = cast(list[str | None], set_cpus[: config.execution.parallel_battles]) + match_cpus = cast(list[str | None], set_cpus[: config.project.parallel_battles]) else: - match_cpus = [set_cpus] * config.execution.parallel_battles + match_cpus = [set_cpus] * config.project.parallel_battles ui.start_battles() async with create_task_group() as tg: for matchup in teams.matchups: @@ -592,8 +592,8 @@ class DynamicProblemConfig(BaseModel): """List of dependencies needed to run the problem""" -class ExecutionConfig(BaseModel): - """Settings that only determine how a match is run, not its result.""" +class ProjectConfig(BaseModel): + """Various project settings.""" parallel_battles: int = 1 """Number of battles exectuted in parallel.""" @@ -630,7 +630,7 @@ class AlgobattleConfig(BaseModel): # funky defaults to force their validation with context info present teams: TeamInfos = Field(default_factory=dict) - execution: ExecutionConfig = Field(default_factory=dict, validate_default=True) + project: ProjectConfig = Field(default_factory=dict, validate_default=True) match: MatchConfig docker: DockerConfig = DockerConfig() problems: dict[str, DynamicProblemConfig] = Field(default_factory=dict) @@ -685,5 +685,5 @@ def as_prog_config(self) -> ProgramConfigView: run_kwargs=self.docker.run.kwargs, generator=self.match.generator, solver=self.match.solver, - mode=self.execution.mode, + mode=self.project.mode, ) diff --git a/tests/configs/test.toml b/tests/configs/test.toml index 15e0c6ce..34437ec4 100644 --- a/tests/configs/test.toml +++ b/tests/configs/test.toml @@ -8,5 +8,5 @@ space = 10 type = "Averaged" num_fights = 1 -[execution] +[project] points = 10 diff --git a/tests/test_match.py b/tests/test_match.py index b4246ac8..1b8773c1 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -7,7 +7,7 @@ from pydantic import ByteSize, ValidationError from algobattle.battle import Fight, Iterated, Averaged -from algobattle.match import ExecutionConfig, Match, AlgobattleConfig, MatchConfig, RunConfig, TeamInfo +from algobattle.match import ProjectConfig, Match, AlgobattleConfig, MatchConfig, RunConfig, TeamInfo from algobattle.program import ProgramRunInfo, Team, Matchup, TeamHandler from .testsproblem.problem import TestProblem @@ -236,7 +236,7 @@ def test_cfg(self): problem="Test Problem", battle=Averaged.Config(num_fights=1), ), - execution=ExecutionConfig(points=10, results=self.configs_path / "results"), + project=ProjectConfig(points=10, results=self.configs_path / "results"), ), ) @@ -252,7 +252,7 @@ def test_cfg_team(self): match=MatchConfig( problem="Test Problem", ), - execution=ExecutionConfig(results=self.configs_path / "results"), + project=ProjectConfig(results=self.configs_path / "results"), ), ) From af8f15dce13acc5054f090d4d2ef166c1db15bf5 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 01:47:24 +0200 Subject: [PATCH 072/113] split match mode setting into its parts --- algobattle/match.py | 10 ++++++---- algobattle/program.py | 11 +++++------ algobattle/util.py | 2 -- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/algobattle/match.py b/algobattle/match.py index 73f7b2e9..53b0370e 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -23,7 +23,6 @@ from algobattle.problem import InstanceT, Problem, SolutionT from algobattle.util import ( ExceptionInfo, - MatchMode, Role, RunningTimer, BaseModel, @@ -597,8 +596,10 @@ class ProjectConfig(BaseModel): parallel_battles: int = 1 """Number of battles exectuted in parallel.""" - mode: MatchMode = "testing" - """Mode of the match.""" + name_images: bool = True + """Whether to give the docker images names.""" + cleanup_images: bool = False + """Whether to clean up the images after we use them.""" set_cpus: str | list[str] | None = None """Wich cpus to run programs on, if it is a list each battle will use a different cpu specification for it.""" points: int = 100 @@ -685,5 +686,6 @@ def as_prog_config(self) -> ProgramConfigView: run_kwargs=self.docker.run.kwargs, generator=self.match.generator, solver=self.match.solver, - mode=self.project.mode, + name_images=self.project.name_images, + cleanup_images=self.project.cleanup_images, ) diff --git a/algobattle/program.py b/algobattle/program.py index b2221f97..be971d6e 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -34,7 +34,6 @@ ExceptionInfo, ExecutionError, ExecutionTimeout, - MatchMode, TempDir, ValidationError, Role, @@ -102,7 +101,8 @@ class ProgramConfigView: run_kwargs: dict[str, Any] generator: RunConfigView solver: RunConfigView - mode: MatchMode + name_images: bool + cleanup_images: bool class ProgramUi(Protocol): @@ -266,7 +266,7 @@ async def build( Raises: BuildError: If the build fails for any reason. """ - if team_name is not None and config.mode == "testing": + if team_name is not None and config.name_images: normalized = team_name.lower().replace(" ", "_") name = f"algobattle_{normalized}_{cls.role.name}" try: @@ -481,7 +481,7 @@ def __enter__(self): return self def __exit__(self, _type: Any, _value: Any, _traceback: Any): - if self.config.mode == "tournament": + if self.config.cleanup_images: self.remove() @@ -817,7 +817,6 @@ class TeamHandler: active: list[Team] = field(default_factory=list) excluded: dict[str, ExceptionInfo] = field(default_factory=dict) - cleanup: bool = True @classmethod async def build( @@ -840,7 +839,7 @@ async def build( Returns: :class:`TeamHandler` containing the info about the participating teams. """ - handler = cls(cleanup=config.mode == "tournament") + handler = cls() ui.start_build_step(infos.keys(), config.build_timeout) for name, info in infos.items(): try: diff --git a/algobattle/util.py b/algobattle/util.py index 7be86311..0abee985 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -37,8 +37,6 @@ class Role(StrEnum): solver = "solver" -MatchMode = Literal["tournament", "testing"] -"""Indicates what type of match is being fought.""" T = TypeVar("T") From ead5881ba4127c08e495e8040339c568cc0c0ca0 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 02:03:16 +0200 Subject: [PATCH 073/113] unify path formatting --- algobattle/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 670f6b58..05922280 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -97,7 +97,7 @@ def save(self) -> None: def default_project_doc(self) -> TomlTable | None: """The default exec config for each problem.""" exec: Any = self._doc.get( - "default_project_config", table().append("results", "./results").append("mode", "testing") + "default_project_config", table().append("results", "results") ) return exec @@ -276,7 +276,7 @@ def init( "teams", table().add( team_name, - table().add("generator", "./generator").add("solver", "./solver"), + table().add("generator", "generator").add("solver", "solver"), ), ) if config.default_project_doc is not None and "project" not in config_doc: From 5ca0b59a2eead389a6ae1b5ebfddd47126671b3e Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 02:12:43 +0200 Subject: [PATCH 074/113] fix typo --- algobattle/problem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/problem.py b/algobattle/problem.py index fafbe40b..94543cfc 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -333,7 +333,7 @@ def load(cls, name: str, dynamic: Mapping[str, DynamicProblemInfo]) -> "AnyProbl Raises: ValueError: If the problem is not specified properly - RuntimeError If the problem's dynamic import fails + RuntimeError: If the problem's dynamic import fails """ if name in dynamic: info = dynamic[name] From 0bf5358d1bd48d9966d28e9e833a802a57b74ed9 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 15:12:14 +0200 Subject: [PATCH 075/113] make init command handle existing problems --- algobattle/cli.py | 52 +++++++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 05922280..7b142659 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -42,7 +42,7 @@ from tomlkit.items import Table as TomlTable from algobattle.battle import Battle -from algobattle.match import AlgobattleConfig, EmptyUi, Match, Ui, ProjectConfig +from algobattle.match import AlgobattleConfig, EmptyUi, Match, MatchConfig, 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 @@ -96,9 +96,7 @@ def save(self) -> None: @property def default_project_doc(self) -> TomlTable | None: """The default exec config for each problem.""" - exec: Any = self._doc.get( - "default_project_config", table().append("results", "results") - ) + exec: Any = self._doc.get("default_project_config", table().append("results", "results")) return exec @cached_property @@ -178,9 +176,13 @@ def init( target: Annotated[ Optional[Path], Argument(file_okay=False, writable=True, help="The folder to initialize.") ] = None, - problem: Annotated[ - Optional[Path], - Option("--problem", "-p", exists=True, dir_okay=False, help="The .algo file to use for this."), + problem_: Annotated[ + Optional[str], + Option( + "--problem", + "-p", + help="Path to the .algo file to use, or the name of an installed problem.", + ), ] = None, language: Annotated[ Optional[Language], Option("--language", "-l", help="The language to use for the programs.") @@ -207,7 +209,26 @@ def init( + ("Bearded Dragons", "Macaws", "Wombats", "Wallabies", "Owls", "Seals", "Octopuses", "Frogs", "Jellyfish") ) - if problem is not None: + if problem_ is None: # use the preexisting config file in the target folder + if target is None: + target = Path() + try: + parsed_config = AlgobattleConfig.from_file(target, reltivize_paths=False) + except ValueError: + console.print("[red]You must either use a problem spec file or target a directory with an existing config.") + raise Abort + console.print("Using existing project data") + if len(parsed_config.teams) == 1: + team_name = next(iter(parsed_config.teams.keys())) + + elif problem_ in Problem.available(): # use a preinstalled problem + if target is None: + target = Path() / problem_ + target.mkdir(parents=True, exist_ok=True) + target.joinpath("algobattle.toml").write_text(f"""[match]\nproblem = "{problem_}""") + parsed_config = AlgobattleConfig(match=MatchConfig(problem=problem_)) + + elif (problem := Path(problem_)).is_file(): # use a problem spec file with TempDir() as unpack_dir: with console.status("Extracting problem data"): with ZipFile(problem) as problem_zip: @@ -237,17 +258,12 @@ def init( else: parsed_config = AlgobattleConfig.from_file(target, reltivize_paths=False) console.print("Using existing problem data") + else: - if target is None: - target = Path() - try: - parsed_config = AlgobattleConfig.from_file(target, reltivize_paths=False) - except ValueError: - console.print("[red]You must either use a problem spec file or target a directory with an existing config.") - raise Abort - console.print("Using existing project data") - if len(parsed_config.teams) == 1: - team_name = next(iter(parsed_config.teams.keys())) + console.print( + "[red]The problem argument is neither the name of an installed problem, nor the path to a problem spec" + ) + raise Abort problem_name = parsed_config.match.problem info = parsed_config.problems.get(problem_name, None) From 346832df135d4b8ca96687e91fe94dbfe8a9ec68 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 15:17:04 +0200 Subject: [PATCH 076/113] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1773cce1..fb1e7c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "pdm.backend" [project] name = "algobattle-base" -version = "4.0.0rc2" +version = "4.0.0rc3" description = "The Algobattle lab course package." readme = "README.md" requires-python = ">=3.11" From 1c67f59975ffa074115f88b9bbde280ac2219c9c Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 15:20:38 +0200 Subject: [PATCH 077/113] fix typo --- algobattle/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 7b142659..039aab7c 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -225,7 +225,7 @@ def init( if target is None: target = Path() / problem_ target.mkdir(parents=True, exist_ok=True) - target.joinpath("algobattle.toml").write_text(f"""[match]\nproblem = "{problem_}""") + target.joinpath("algobattle.toml").write_text(f"""[match]\nproblem = "{problem_}"\n""") parsed_config = AlgobattleConfig(match=MatchConfig(problem=problem_)) elif (problem := Path(problem_)).is_file(): # use a problem spec file From 28d21364428f37f6d679f09e9f9e4be4a8920d27 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 17:40:49 +0200 Subject: [PATCH 078/113] lock deps in pyproject.toml --- pyproject.toml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb1e7c1b..c3a2250c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,24 +20,24 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "docker>=6.1.2", - "pydantic>=2.0.3", - "anyio>=3.6.2", - "typer[all]>=0.9.0", - "typing-extensions>=4.7.1", - "tomlkit>=0.12.1", - "jinja2>=3.1.2", + "docker~=6.1.3", + "pydantic~=2.3.0", + "anyio~=4.0.0", + "typer[all]~=0.9.0", + "typing-extensions~=4.8.0", + "tomlkit~=0.12.1", + "jinja2~=3.1.2", ] [project.optional-dependencies] dev = [ - "black", - "flake8", - "flake8-docstrings", - "mkdocs", - "mkdocs-material", - "pymdown-extensions", - "mkdocstrings[python]", - "mdx_include", + "black~=23.7.0", + "flake8~=6.0.0", + "flake8-docstrings~=1.7.0", + "mkdocs~=1.4.3", + "mkdocs-material~=9.1.18", + "pymdown-extensions~=10.0.1", + "mkdocstrings[python]~=0.22.0", + "mdx_include~=1.4.2", ] [project.scripts] From 3c0de3f2e32c2d24e6bb33211804304719fbcbf9 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 20:07:26 +0200 Subject: [PATCH 079/113] fix typo --- algobattle/cli.py | 6 +++--- algobattle/match.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 039aab7c..a3cd7acd 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -213,7 +213,7 @@ def init( if target is None: target = Path() try: - parsed_config = AlgobattleConfig.from_file(target, reltivize_paths=False) + parsed_config = AlgobattleConfig.from_file(target, relativize_paths=False) except ValueError: console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort @@ -234,7 +234,7 @@ def init( with ZipFile(problem) as problem_zip: problem_zip.extractall(unpack_dir) - parsed_config = AlgobattleConfig.from_file(unpack_dir, reltivize_paths=False) + parsed_config = AlgobattleConfig.from_file(unpack_dir, relativize_paths=False) if target is None: target = Path() / parsed_config.match.problem @@ -256,7 +256,7 @@ def init( path.rename(target / path.name) console.print("Unpacked problem data") else: - parsed_config = AlgobattleConfig.from_file(target, reltivize_paths=False) + parsed_config = AlgobattleConfig.from_file(target, relativize_paths=False) console.print("Using existing problem data") else: diff --git a/algobattle/match.py b/algobattle/match.py index 53b0370e..8953d7d1 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -653,13 +653,13 @@ def problem(self) -> Problem[Any, Any]: return Problem.load(self.match.problem, self.problems) @classmethod - def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, reltivize_paths: bool = True) -> Self: + def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, relativize_paths: bool = True) -> Self: """Parses a config object from a toml file. Args: file: Path to the file, or a directory containing one called 'algobattle.toml'. ignore_uninstalled: Whether to raise errors if the specified battle type is not installed. - reltivize_paths: Wether to relativize paths to the config's location rather than the cwd. + relativize_paths: Wether to relativize paths to the config's location rather than the cwd. """ Battle.load_entrypoints() if not file.is_file(): @@ -672,7 +672,7 @@ def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, reltivize_pa except tomllib.TOMLDecodeError as e: raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}") context: dict[str, Any] = {"ignore_uninstalled": ignore_uninstalled} - if reltivize_paths: + if relativize_paths: context["base_path"] = file.parent return cls.model_validate(config_dict, context=context) From 0722a9ffcadfed9f343ba38dc3b40289d3004d57 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 20:16:58 +0200 Subject: [PATCH 080/113] improve cli error messaging --- algobattle/cli.py | 7 +++++-- algobattle/match.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index a3cd7acd..92393252 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -17,7 +17,7 @@ from zipfile import ZipFile from anyio import run as run_async_fn -from pydantic import ConfigDict, Field, ValidationError +from pydantic import Field, ValidationError from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch from rich.console import Group, RenderableType, Console from rich.live import Live @@ -214,9 +214,12 @@ def init( target = Path() try: parsed_config = AlgobattleConfig.from_file(target, relativize_paths=False) - except ValueError: + except FileNotFoundError: console.print("[red]You must either use a problem spec file or target a directory with an existing config.") raise Abort + except ValueError as e: + console.print("[red]The Algobattle config file is not formatted properly\n", e) + raise Abort console.print("Using existing project data") if len(parsed_config.teams) == 1: team_name = next(iter(parsed_config.teams.keys())) diff --git a/algobattle/match.py b/algobattle/match.py index 8953d7d1..cfdfdde0 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -666,7 +666,7 @@ def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, relativize_p if file.joinpath("algobattle.toml").is_file(): file /= "algobattle.toml" else: - raise ValueError("The path does not point to an Algobattle project") + raise FileNotFoundError("The path does not point to an Algobattle project") try: config_dict = tomllib.loads(file.read_text()) except tomllib.TOMLDecodeError as e: From 70a1e9d3b20bc6c8799dbc454107550df21f7406 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 20:27:04 +0200 Subject: [PATCH 081/113] add serde derive to rust template --- algobattle/templates/rust/Cargo.toml.jinja | 1 + 1 file changed, 1 insertion(+) diff --git a/algobattle/templates/rust/Cargo.toml.jinja b/algobattle/templates/rust/Cargo.toml.jinja index 672b83e1..76a4cccc 100644 --- a/algobattle/templates/rust/Cargo.toml.jinja +++ b/algobattle/templates/rust/Cargo.toml.jinja @@ -8,5 +8,6 @@ authors = ["{{ team }}"] [dependencies] {% if instance_json or solution_json %} +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" {% endif %} From 3a10297d2bb007dd3e3ef39b96f8a6d699737cda Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 22:37:17 +0200 Subject: [PATCH 082/113] add help message to cli --- algobattle/cli.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 92393252..5afdf0c6 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -51,8 +51,12 @@ __all__ = ("app",) +help_message = """The Algobattle command line program. -app = Typer(pretty_exceptions_show_locals=True) +You can use this to setup your workspace, develop programs, run matches, and more! +For more detailed documentation, visit our website at http://algobattle.org/docs/tutorial +""" +app = Typer(pretty_exceptions_show_locals=True, help=help_message) console = Console() From bd4f87616c884611e7fd30c48b72ec189f87d944 Mon Sep 17 00:00:00 2001 From: Imogen Date: Mon, 18 Sep 2023 23:02:35 +0200 Subject: [PATCH 083/113] more accurate cli build view --- algobattle/cli.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 5afdf0c6..0fd106f7 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -29,7 +29,6 @@ BarColumn, MofNCompleteColumn, TimeElapsedColumn, - TaskID, ProgressColumn, Task, ) @@ -128,7 +127,9 @@ def install_cmd(self) -> list[str]: @app.command("run") def run_match( - path: Annotated[Path, Argument(exists=True, help="Path to either a config file or a directory containing one.")], + path: Annotated[ + Path, Argument(exists=True, help="Path to either a config file or a directory containing one.") + ] = Path(), ui: Annotated[bool, Option(help="Whether to show the CLI UI during match execution.")] = True, save: Annotated[bool, Option(help="Whether to save the match result.")] = True, ) -> Match: @@ -558,12 +559,12 @@ def __init__(self, teams: Iterable[str]) -> None: LazySpinnerColumn(), BarColumn(bar_width=10), TimeElapsedColumn(), + TextColumn("[cyan]{task.fields[status]}"), ) self.overall_task = self.overall_progress.add_task("[blue]Building programs", total=2 * len(teams)) - team_dict: dict[str, TaskID] = {} - for team in teams: - team_dict[team] = self.team_progress.add_task(team, start=False, total=2, failed="", name=team) - self.teams = team_dict + self.teams = { + team: self.team_progress.add_task(team, start=False, total=2, status="", name=team) for team in teams + } super().__init__(self.overall_progress, self.team_progress) @@ -682,8 +683,12 @@ def start_build(self, team: str, role: Role) -> None: view = self.renderable.renderable assert isinstance(view, BuildView) task = view.teams[team] - view.team_progress.start_task(task) - view.team_progress.advance(task) + match role: + case Role.generator: + view.team_progress.start_task(task) + case Role.solver: + view.team_progress.advance(task) + view.overall_progress.advance(view.overall_task, 1) @override def finish_build(self, team: str, success: bool) -> None: @@ -692,7 +697,7 @@ def finish_build(self, team: str, success: bool) -> None: assert isinstance(view, BuildView) task = view.teams[team] current = view.team_progress._tasks[task].completed - view.team_progress.update(task, completed=2, failed="" if success else ":warning:") + view.team_progress.update(task, completed=2, status="" if success else "[red]failed!") view.overall_progress.advance(view.overall_task, 2 - current) @override @@ -723,9 +728,9 @@ def end_fight(self, matchup: Matchup) -> None: table = panel._fights_table() for i, fight in zip(range(len(battle.fights), len(battle.fights) - len(fights), -1), fights): if fight.generator.error: - info = f"Generator failed: {fight.generator.error.message}" + info = f"[red]Generator failed[/]: {fight.generator.error.message}" elif fight.solver and fight.solver.error: - info = f"Solver failed: {fight.solver.error.message}" + info = f"[red]Solver failed[/]: {fight.solver.error.message}" else: info = "" table.add_row(str(i), str(fight.max_size), f"{fight.score:.1%}", info) From 4ae08b8323604a3ec0233c550ac3a92042aec258 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 00:10:50 +0200 Subject: [PATCH 084/113] default to 20 seconds program timeout --- algobattle/match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/match.py b/algobattle/match.py index cfdfdde0..8638960b 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -546,7 +546,7 @@ class DockerConfig(BaseModel): class RunConfig(BaseModel): """Parameters determining how a program is run.""" - timeout: WithNone[TimeDeltaFloat] = 30 + timeout: WithNone[TimeDeltaFloat] = 20 """Timeout in seconds, or `false` for no timeout.""" space: WithNone[ByteSizeInt] = None """Maximum memory space available, or `false` for no limitation. From 367df71783f54267d9dfab489c682d713b993e5d Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 00:18:55 +0200 Subject: [PATCH 085/113] cleanup curr fight and battle data display --- algobattle/cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 0fd106f7..7103084a 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -582,7 +582,7 @@ def __init__(self, max_size: int) -> None: ) self.generator = self.progress.add_task("Generator", start=False, total=1, message="") self.solver = self.progress.add_task("Solver", start=False, total=1, message="") - super().__init__(self.progress, title="Current Fight", width=30) + super().__init__(self.progress, title="[green]Current Fight", width=30) class BattlePanel(Panel): @@ -597,7 +597,7 @@ def __init__(self, matchup: Matchup) -> None: def _make_renderable(self) -> RenderableType: return Group( - Columns((self._battle_data, self._curr_fight), expand=True, equal=True, align="center"), + Columns((self._curr_fight, self._battle_data), align="left"), self._past_fights, ) @@ -607,7 +607,7 @@ def battle_data(self) -> RenderableType: @battle_data.setter def battle_data(self, value: RenderableType) -> None: - self._battle_data = value + self._battle_data = Panel(value, title="[green]Battle Data") self.renderable = self._make_renderable() @property @@ -761,7 +761,7 @@ def end_program(self, matchup: Matchup, role: Role, runtime: float) -> None: @override def update_battle_data(self, matchup: Matchup, data: Battle.UiData) -> None: self.battle_panels[matchup].battle_data = Group( - "[green]Battle Data:", *(f"[orchid]{key}[/]: [cyan]{value}" for key, value in data.model_dump().items()) + *(f"[orchid]{key}[/]: [cyan]{value}" for key, value in data.model_dump().items()) ) From 01b7a2b5454fe8de9121336a6651871c49155a62 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 00:23:54 +0200 Subject: [PATCH 086/113] include runtime as default info --- algobattle/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 7103084a..37f99d71 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -732,7 +732,8 @@ def end_fight(self, matchup: Matchup) -> None: elif fight.solver and fight.solver.error: info = f"[red]Solver failed[/]: {fight.solver.error.message}" else: - info = "" + assert fight.solver is not None + info = f"Runtimes: gen {fight.generator.runtime:.1f}s, sol {fight.solver.runtime:.1f}s" table.add_row(str(i), str(fight.max_size), f"{fight.score:.1%}", info) panel.past_fights = table From 45c48e64f2755641365ab5d77e993353610da854 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 01:45:06 +0200 Subject: [PATCH 087/113] make docker interaction cancellable --- algobattle/program.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/algobattle/program.py b/algobattle/program.py index be971d6e..e7e4ea1b 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -286,6 +286,7 @@ async def build( config.build_timeout, dockerfile, config.build_kwargs, + cancellable=True, ) if old_image is not None: old_image.reload() @@ -404,7 +405,7 @@ async def _run_inner( if ui is not None: ui.start_program(self.role, specs.timeout) try: - runtime = await run_sync(self._run_daemon_call, container, specs.timeout) + runtime = await run_sync(self._run_daemon_call, container, specs.timeout, cancellable=True) except ExecutionError as e: raise _WrappedException(e, e.runtime) finally: From 4fa0ed8cd5a32217f1c8d26ea622bcbd1d39ae68 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 02:01:19 +0200 Subject: [PATCH 088/113] include max size in current fight display --- algobattle/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 37f99d71..c98f7f66 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -582,7 +582,7 @@ def __init__(self, max_size: int) -> None: ) self.generator = self.progress.add_task("Generator", start=False, total=1, message="") self.solver = self.progress.add_task("Solver", start=False, total=1, message="") - super().__init__(self.progress, title="[green]Current Fight", width=30) + super().__init__(Group(f"Max size: {self.max_size}", self.progress), title="[green]Current Fight", width=30) class BattlePanel(Panel): @@ -662,7 +662,7 @@ def display_match(match: Match) -> RenderableType: Column("Generating", justify="center"), Column("Solving", justify="center"), Column("Result", justify="right"), - title="[blue]Match overview", + title="[green]Match overview", ) for generating, battles in match.results.items(): for solving, result in battles.items(): From 1c980827c223a109843b137ccad9836010c83b28 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 02:18:00 +0200 Subject: [PATCH 089/113] use unifying theme --- algobattle/cli.py | 92 ++++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index c98f7f66..338f60e4 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -36,6 +36,7 @@ from rich.text import Text from rich.columns import Columns from rich.prompt import Prompt, Confirm +from rich.theme import Theme from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table from tomlkit.exceptions import ParseError from tomlkit.items import Table as TomlTable @@ -56,7 +57,15 @@ For more detailed documentation, visit our website at http://algobattle.org/docs/tutorial """ app = Typer(pretty_exceptions_show_locals=True, help=help_message) -console = Console() +theme = Theme({ + "success": "green", + "warning": "orange3", + "error": "red", + "attention": "magenta2", + "heading": "blue", + "info": "dim cyan", +}) +console = Console(theme=theme) class _InstallMode(StrEnum): @@ -107,8 +116,8 @@ def install_cmd(self) -> list[str]: cmd = [sys.executable, "-m", "pip", "install"] if self.general.install_mode is None: command_str: str = Prompt.ask( - "[cyan]Do you want to install problems normally, or into the user directory?[/] If you're using an " - "environment manager like venv or conda you should install them normally, otherwise user installs " + "[attention]Do you want to install problems normally, or into the user directory?[/] If you're using " + "an environment manager like venv or conda you should install them normally, otherwise user installs " "might be better.", default="normal", choices=["normal", "user"], @@ -161,7 +170,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: dir = target / role if dir.exists(): replace = Confirm.ask( - f"[magenta2]The targeted directory already contains a {role}, do you want to replace it?", + f"[attention]The targeted directory already contains a {role}, do you want to replace it?", default=True, ) if replace: @@ -173,7 +182,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: dir.mkdir(parents=True, exist_ok=True) with console.status(f"Initializing {role}"): write_templates(dir, lang, TemplateArgs(program=role.value, **args)) - console.print(f"Created a {lang} {role} in [cyan]{dir}") + console.print(f"Created a {lang} {role} in {dir}") @app.command() @@ -220,10 +229,10 @@ def init( try: parsed_config = AlgobattleConfig.from_file(target, relativize_paths=False) except FileNotFoundError: - console.print("[red]You must either use a problem spec file or target a directory with an existing config.") + console.print("[error]You must use a problem spec file or target a directory with an existing config.") raise Abort except ValueError as e: - console.print("[red]The Algobattle config file is not formatted properly\n", e) + console.print("[error]The Algobattle config file is not formatted properly\n", e) raise Abort console.print("Using existing project data") if len(parsed_config.teams) == 1: @@ -250,7 +259,8 @@ def init( problem_data = list(unpack_dir.iterdir()) if any(((target / path.name).exists() for path in problem_data)): copy_problem_data = Confirm.ask( - "[magenta2]The target directory already contains an algobattle project, do you want to replace it?", + "[attention]The target directory already contains an algobattle project, " + "do you want to replace it?", default=True, ) else: @@ -269,7 +279,7 @@ def init( else: console.print( - "[red]The problem argument is neither the name of an installed problem, nor the path to a problem spec" + "[error]The problem argument is neither the name of an installed problem, nor the path to a problem spec" ) raise Abort @@ -288,10 +298,10 @@ def init( console.print(line.strip("\n")) error = "".join(installer.stderr.readlines()) if installer.returncode: - console.print(f"[red]Couldn't install the dependencies[/]\n{error}") + console.print(f"[error]Couldn't install the dependencies[/]\n{error}") raise Abort else: - console.print(f"[green]Installed dependencies of {problem_name}") + console.print(f"[success]Installed dependencies of {problem_name}") with console.status("Initializing metadata"): config_doc = parse_toml(target.joinpath("algobattle.toml").read_text()) @@ -340,7 +350,7 @@ def init( elif not target.joinpath("solver").exists(): _init_program(target, Language.plain, template_args, Role.solver) - console.print(f"[green]Success![/] initialized algobattle project data in [cyan]{target}[/]") + console.print(f"[success]Initialized algobattle project[/] in {target}") class TestErrors(BaseModel): @@ -358,7 +368,7 @@ def test( ) -> None: """Tests whether the programs install successfully and run on dummy instances without crashing.""" if not (folder.is_file() or folder.joinpath("algobattle.toml").is_file()): - console.print("[red]The folder does not contain an Algobattle project") + console.print("[error]The folder does not contain an Algobattle project") raise Abort config = AlgobattleConfig.from_file(folder) problem = config.problem @@ -377,17 +387,17 @@ async def gen_builder() -> Generator: try: with run_async_fn(gen_builder) as gen: - console.print("[green]Generator built successfully") + console.print("[success]Generator built successfully") with console.status("Running generator"): instance = gen.test() if isinstance(instance, ExceptionInfo): - console.print("[red]Generator didn't run successfully") + console.print("[error]Generator didn't run successfully") errors.generator_run = instance instance = None else: - console.print("[green]Generator ran successfully") + console.print("[success]Generator ran successfully") except BuildError as e: - console.print("[red]Generator didn't build successfully") + console.print("[error]Generator didn't build successfully") errors.generator_build = ExceptionInfo.from_exception(e) instance = None @@ -401,21 +411,21 @@ async def sol_builder() -> Solver: try: with run_async_fn(sol_builder) as sol: - console.print("[green]Solver built successfully") + console.print("[success]Solver built successfully") instance = instance or cast(Instance, problem.test_instance) if instance: with console.status("Running solver"): sol_error = sol.test(instance) if isinstance(sol_error, ExceptionInfo): - console.print("[red]Solver didn't run successfully") + console.print("[error]Solver didn't run successfully") errors.solver_run = sol_error else: - console.print("[green]Solver ran successfully") + console.print("[success]Solver ran successfully") else: - console.print("[orange3]Cannot test running the solver") + console.print("[warning]Cannot test running the solver") except BuildError as e: - console.print("[red]Solver didn't build successfully") + console.print("[error]Solver didn't build successfully") errors.solver_build = ExceptionInfo.from_exception(e) instance = None @@ -456,13 +466,13 @@ def package( elif Path("problem").is_dir(): problem_path = Path("problem") else: - console.print("[red]Couldn't find a problem package") + console.print("[error]Couldn't find a problem package") raise Abort if config is None: if problem_path.parent.joinpath("algobattle.toml").is_file(): config = problem_path.parent / "algobattle.toml" else: - console.log("[red]Couldn't find a config file") + console.log("[error]Couldn't find a config file") raise Abort if description is None: match list(problem_path.parent.resolve().glob("description.*")): @@ -472,7 +482,7 @@ def package( description = desc case _: console.print( - "[red]Found multiple potential description files[/], explicitly specify which you want to include" + "[error]Found multiple potential description files[/], explicitly specify which you want to include" ) raise Abort @@ -480,14 +490,14 @@ def package( config_doc = parse_toml(config.read_text()) parsed_config = AlgobattleConfig.from_file(config) except (ValidationError, ParseError) as e: - console.print(f"[red]Improperly formatted config file\nError: {e}") + console.print(f"[error]Improperly formatted config file\nError: {e}") raise Abort problem_name = parsed_config.match.problem try: with console.status("Loading problem"): Problem.load_file(problem_name, problem_path) except (ValueError, RuntimeError) as e: - console.print(f"[red]Couldn't load the problem file[/]\nError: {e}") + console.print(f"[error]Couldn't load the problem file[/]\nError: {e}") raise Abort problem_info = parsed_config.problems[problem_name] @@ -517,7 +527,7 @@ def package( file.writestr("algobattle.toml", dumps_toml(config_doc)) if description is not None: file.write(description, description.name) - console.print("[green]Packaged Algobattle project into[/]", out) + console.print("[success]Packaged Algobattle project[/] into", out) class TimerTotalColumn(ProgressColumn): @@ -555,13 +565,13 @@ def __init__(self, teams: Iterable[str]) -> None: transient=True, ) self.team_progress = Progress( - TextColumn("[cyan]{task.fields[name]}"), + TextColumn("{task.fields[name]}"), LazySpinnerColumn(), BarColumn(bar_width=10), TimeElapsedColumn(), - TextColumn("[cyan]{task.fields[status]}"), + TextColumn("{task.fields[status]}"), ) - self.overall_task = self.overall_progress.add_task("[blue]Building programs", total=2 * len(teams)) + self.overall_task = self.overall_progress.add_task("[heading]Building programs", total=2 * len(teams)) self.teams = { team: self.team_progress.add_task(team, start=False, total=2, status="", name=team) for team in teams } @@ -582,7 +592,7 @@ def __init__(self, max_size: int) -> None: ) self.generator = self.progress.add_task("Generator", start=False, total=1, message="") self.solver = self.progress.add_task("Solver", start=False, total=1, message="") - super().__init__(Group(f"Max size: {self.max_size}", self.progress), title="[green]Current Fight", width=30) + super().__init__(Group(f"Max size: {self.max_size}", self.progress), title="[heading]Current Fight", width=30) class BattlePanel(Panel): @@ -593,7 +603,7 @@ def __init__(self, matchup: Matchup) -> None: self._battle_data: RenderableType = "" self._curr_fight: FightPanel | Literal[""] = "" self._past_fights = self._fights_table() - super().__init__(self._make_renderable(), title=f"Battle {self.matchup}") + super().__init__(self._make_renderable(), title=f"[heading]Battle {self.matchup}") def _make_renderable(self) -> RenderableType: return Group( @@ -607,7 +617,7 @@ def battle_data(self) -> RenderableType: @battle_data.setter def battle_data(self, value: RenderableType) -> None: - self._battle_data = Panel(value, title="[green]Battle Data") + self._battle_data = Panel(value, title="[heading]Battle Data") self.renderable = self._make_renderable() @property @@ -634,7 +644,7 @@ def _fights_table(self) -> Table: Column("Max size", justify="right"), Column("Score", justify="right"), "Detail", - title="Most recent fights", + title="[heading]Most recent fights", ) @@ -645,7 +655,7 @@ class CliUi(Live, Ui): def __init__(self) -> None: self.battle_panels: dict[Matchup, BattlePanel] = {} - super().__init__(None, refresh_per_second=10, transient=True) + super().__init__(None, refresh_per_second=10, transient=True, console=console) def __enter__(self) -> Self: return cast(Self, super().__enter__()) @@ -662,7 +672,7 @@ def display_match(match: Match) -> RenderableType: Column("Generating", justify="center"), Column("Solving", justify="center"), Column("Result", justify="right"), - title="[green]Match overview", + title="[heading]Match overview", ) for generating, battles in match.results.items(): for solving, result in battles.items(): @@ -697,7 +707,7 @@ def finish_build(self, team: str, success: bool) -> None: assert isinstance(view, BuildView) task = view.teams[team] current = view.team_progress._tasks[task].completed - view.team_progress.update(task, completed=2, status="" if success else "[red]failed!") + view.team_progress.update(task, completed=2, status="" if success else "[error]failed!") view.overall_progress.advance(view.overall_task, 2 - current) @override @@ -728,9 +738,9 @@ def end_fight(self, matchup: Matchup) -> None: table = panel._fights_table() for i, fight in zip(range(len(battle.fights), len(battle.fights) - len(fights), -1), fights): if fight.generator.error: - info = f"[red]Generator failed[/]: {fight.generator.error.message}" + info = f"[error]Generator failed[/]: {fight.generator.error.message}" elif fight.solver and fight.solver.error: - info = f"[red]Solver failed[/]: {fight.solver.error.message}" + info = f"[error]Solver failed[/]: {fight.solver.error.message}" else: assert fight.solver is not None info = f"Runtimes: gen {fight.generator.runtime:.1f}s, sol {fight.solver.runtime:.1f}s" @@ -762,7 +772,7 @@ def end_program(self, matchup: Matchup, role: Role, runtime: float) -> None: @override def update_battle_data(self, matchup: Matchup, data: Battle.UiData) -> None: self.battle_panels[matchup].battle_data = Group( - *(f"[orchid]{key}[/]: [cyan]{value}" for key, value in data.model_dump().items()) + *(f"[orchid]{key}[/]: [info]{value}" for key, value in data.model_dump().items()) ) From f1cb5d800ee5eb13dea0ff99e2b076ae2c50c03f Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 02:32:11 +0200 Subject: [PATCH 090/113] use rules to seperate battles --- algobattle/cli.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 338f60e4..809cd716 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -37,6 +37,8 @@ from rich.columns import Columns from rich.prompt import Prompt, Confirm from rich.theme import Theme +from rich.rule import Rule +from rich.padding import Padding from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table from tomlkit.exceptions import ParseError from tomlkit.items import Table as TomlTable @@ -595,7 +597,7 @@ def __init__(self, max_size: int) -> None: super().__init__(Group(f"Max size: {self.max_size}", self.progress), title="[heading]Current Fight", width=30) -class BattlePanel(Panel): +class BattlePanel(Group): """Panel that displays the state of a battle.""" def __init__(self, matchup: Matchup) -> None: @@ -603,13 +605,14 @@ def __init__(self, matchup: Matchup) -> None: self._battle_data: RenderableType = "" self._curr_fight: FightPanel | Literal[""] = "" self._past_fights = self._fights_table() - super().__init__(self._make_renderable(), title=f"[heading]Battle {self.matchup}") + super().__init__(*self._make_renderable()) - def _make_renderable(self) -> RenderableType: - return Group( + def _make_renderable(self) -> list[RenderableType]: + return [ + Padding(Rule(title=f"[heading]Battle {self.matchup}"), pad=(1, 0)), Columns((self._curr_fight, self._battle_data), align="left"), self._past_fights, - ) + ] @property def battle_data(self) -> RenderableType: @@ -618,7 +621,7 @@ def battle_data(self) -> RenderableType: @battle_data.setter def battle_data(self, value: RenderableType) -> None: self._battle_data = Panel(value, title="[heading]Battle Data") - self.renderable = self._make_renderable() + self._render = list(self._make_renderable()) @property def curr_fight(self) -> FightPanel | Literal[""]: @@ -627,7 +630,7 @@ def curr_fight(self) -> FightPanel | Literal[""]: @curr_fight.setter def curr_fight(self, value: FightPanel | Literal[""]) -> None: self._curr_fight = value - self.renderable = self._make_renderable() + self._render = self._make_renderable() @property def past_fights(self) -> Table: @@ -636,7 +639,7 @@ def past_fights(self) -> Table: @past_fights.setter def past_fights(self, value: Table) -> None: self._past_fights = value - self.renderable = self._make_renderable() + self._render = self._make_renderable() def _fights_table(self) -> Table: return Table( From cc24b3617e262b1bfc2e8f2d5b32ba137a9b2670 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 02:44:29 +0200 Subject: [PATCH 091/113] make stopped match output nicer --- algobattle/cli.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 809cd716..a0dbad48 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -151,10 +151,11 @@ def run_match( with CliUi() if ui else EmptyUi() as ui_obj: run_async_fn(result.run, config, ui_obj) except KeyboardInterrupt: - console.print("Received keyboard interrupt, terminating execution.") + console.print("[error]Stopping match execution") finally: try: - console.print(CliUi.display_match(result)) + if any(r for r in result.results.values()): + console.print(CliUi.display_match(result)) if config.project.points > 0: points = result.calculate_points(config.project.points) for team, pts in points.items(): @@ -165,7 +166,7 @@ def run_match( config.project.results.joinpath(f"{timestamp()}.json").write_text(res_string) return result except KeyboardInterrupt: - raise Exit + raise Abort def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: Role) -> None: @@ -684,7 +685,7 @@ def display_match(match: Match) -> RenderableType: else: res = ":warning:" table.add_row(generating, solving, res) - return table + return Padding(table, pad=(1, 0, 0, 0)) @override def start_build_step(self, teams: Iterable[str], timeout: float | None) -> None: From 3b20bfa78f5dd7d428fc939db03ebf9bc3a9052f Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 02:48:49 +0200 Subject: [PATCH 092/113] live update match overview --- algobattle/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/algobattle/cli.py b/algobattle/cli.py index a0dbad48..bb0fd49b 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -778,6 +778,7 @@ def update_battle_data(self, matchup: Matchup, data: Battle.UiData) -> None: self.battle_panels[matchup].battle_data = Group( *(f"[orchid]{key}[/]: [info]{value}" for key, value in data.model_dump().items()) ) + self._update_renderable() if __name__ == "__main__": From c6325420345daa9b2ac4d9f838417b02a1de6473 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 02:49:05 +0200 Subject: [PATCH 093/113] iterated live result update --- algobattle/battle.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/algobattle/battle.py b/algobattle/battle.py index eb12fa2c..64bfeaa9 100644 --- a/algobattle/battle.py +++ b/algobattle/battle.py @@ -419,10 +419,11 @@ async def run_battle(self, fight: FightHandler, config: Config, min_size: int, u base_increment = 0 alive = True reached = 0 + self.results.append(0) cap = config.maximum_size current = min_size while alive: - ui.update_battle_data(self.UiData(reached=self.results + [reached], cap=cap)) + ui.update_battle_data(self.UiData(reached=self.results, cap=cap)) result = await fight.run(current) score = result.score if score < config.minimum_score: @@ -436,7 +437,7 @@ async def run_battle(self, fight: FightHandler, config: Config, min_size: int, u alive = True elif current > reached and alive: # We solved an instance of bigger size than before - reached = current + self.results[-1] = reached = current if current + 1 > cap: alive = False @@ -448,7 +449,7 @@ async def run_battle(self, fight: FightHandler, config: Config, min_size: int, u # We have failed at this value of n already, reset the step size! current -= base_increment**config.exponent - 1 base_increment = 1 - self.results.append(reached) + self.results[-1] = reached def score(self) -> float: """Averages the highest instance size reached in each round.""" From badd204cdb9217159edf7a03842ea90c6bdc9e93 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 02:56:56 +0200 Subject: [PATCH 094/113] prettier battle section headings --- algobattle/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index bb0fd49b..e64cf670 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -610,7 +610,7 @@ def __init__(self, matchup: Matchup) -> None: def _make_renderable(self) -> list[RenderableType]: return [ - Padding(Rule(title=f"[heading]Battle {self.matchup}"), pad=(1, 0)), + Padding(Rule(title=f"[heading]{self.matchup}"), pad=(1, 0)), Columns((self._curr_fight, self._battle_data), align="left"), self._past_fights, ] From ca4151c56bfc46de13a71cdae21b87bd5f5b799e Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 03:18:46 +0200 Subject: [PATCH 095/113] print match result output path to cli --- algobattle/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index e64cf670..e84ca766 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -163,7 +163,9 @@ def run_match( if save: res_string = result.model_dump_json(exclude_defaults=True) - config.project.results.joinpath(f"{timestamp()}.json").write_text(res_string) + out_path = config.project.results.joinpath(f"{timestamp()}.json") + out_path.write_text(res_string) + console.print("Saved match result to ", out_path) return result except KeyboardInterrupt: raise Abort From 102e7209ebc97642afa93e0a5eef90e421f6703a Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 03:23:20 +0200 Subject: [PATCH 096/113] better phrasing --- algobattle/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index e84ca766..d4720de8 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -159,7 +159,7 @@ def run_match( if config.project.points > 0: points = result.calculate_points(config.project.points) for team, pts in points.items(): - print(f"Team {team} gained {pts:.1f} points.") + print(f"Team {team} scored {pts:.1f} points.") if save: res_string = result.model_dump_json(exclude_defaults=True) From d8056428e5e9bc15a7aa43960f8fd264e33ea797 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 03:34:22 +0200 Subject: [PATCH 097/113] print points after match run --- algobattle/cli.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index d4720de8..6bcc90b8 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -5,6 +5,7 @@ from enum import StrEnum from functools import cached_property import json +import operator from os import environ from pathlib import Path from random import choice @@ -154,12 +155,16 @@ def run_match( console.print("[error]Stopping match execution") finally: try: - if any(r for r in result.results.values()): - console.print(CliUi.display_match(result)) if config.project.points > 0: points = result.calculate_points(config.project.points) - for team, pts in points.items(): - print(f"Team {team} scored {pts:.1f} points.") + leaderboard = Table( + Column("Team", justify="center"), + Column("Points", justify="right"), + title="[heading]Leaderboard", + ) + for team, pts in sorted(points.items(), key=operator.itemgetter(1)): + leaderboard.add_row(team, f"{pts:.1f}") + console.print(Padding(leaderboard, (1, 0, 0, 0))) if save: res_string = result.model_dump_json(exclude_defaults=True) From 6952130df122c1227f526d28dd8c5c7206110de6 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 04:03:31 +0200 Subject: [PATCH 098/113] add default lanugage config --- algobattle/cli.py | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 6bcc90b8..0d3fb1c8 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -19,7 +19,7 @@ from anyio import run as run_async_fn from pydantic import Field, ValidationError -from typer import Exit, Typer, Argument, Option, Abort, get_app_dir, launch +from typer import Typer, Argument, Option, Abort, get_app_dir, launch from rich.console import Group, RenderableType, Console from rich.live import Live from rich.table import Table, Column @@ -40,7 +40,7 @@ from rich.theme import Theme from rich.rule import Rule from rich.padding import Padding -from tomlkit import TOMLDocument, parse as parse_toml, dumps as dumps_toml, table +from tomlkit import TOMLDocument, comment, parse as parse_toml, dumps as dumps_toml, table, nl as toml_newline from tomlkit.exceptions import ParseError from tomlkit.items import Table as TomlTable @@ -60,14 +60,16 @@ For more detailed documentation, visit our website at http://algobattle.org/docs/tutorial """ app = Typer(pretty_exceptions_show_locals=True, help=help_message) -theme = Theme({ - "success": "green", - "warning": "orange3", - "error": "red", - "attention": "magenta2", - "heading": "blue", - "info": "dim cyan", -}) +theme = Theme( + { + "success": "green", + "warning": "orange3", + "error": "red", + "attention": "magenta2", + "heading": "blue", + "info": "dim cyan", + } +) console = Console(theme=theme) @@ -79,6 +81,8 @@ class _InstallMode(StrEnum): class _General(BaseModel): team_name: str | None = None install_mode: _InstallMode | None = None + generator_language: Language = Language.plain + solver_language: Language = Language.plain class CliConfig(BaseModel): @@ -93,7 +97,15 @@ def init_file(cls) -> None: """Initializes the config file if it does not exist.""" if not cls.path.is_file(): cls.path.parent.mkdir(parents=True, exist_ok=True) - cls.path.write_text("# The Algobattle cli configuration\n") + general = table().append("generator_language", "plain").append("solver_language", "plain") + doc = ( + table() + .add(comment("# The Algobattle cli configuration")) + .add(toml_newline()) + .append("general", general) + .add(toml_newline()) + ) + cls.path.write_text(dumps_toml(doc)) @classmethod def load(cls) -> Self: @@ -354,11 +366,11 @@ def init( if generator is not None: _init_program(target, generator, template_args, Role.generator) elif not target.joinpath("generator").exists(): - _init_program(target, Language.plain, template_args, Role.generator) + _init_program(target, config.general.generator_language, template_args, Role.generator) if solver is not None: _init_program(target, solver, template_args, Role.solver) elif not target.joinpath("solver").exists(): - _init_program(target, Language.plain, template_args, Role.solver) + _init_program(target, config.general.solver_language, template_args, Role.solver) console.print(f"[success]Initialized algobattle project[/] in {target}") From 8f6023090d62116eebfc0b3198c3df39d2f9ed12 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 04:09:47 +0200 Subject: [PATCH 099/113] include defaults in autogenerated cli config --- algobattle/cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 0d3fb1c8..b8967512 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -87,7 +87,7 @@ class _General(BaseModel): class CliConfig(BaseModel): general: _General = Field(default_factory=dict, validate_default=True) - default_project_config: ProjectConfig | None = Field(default=None) + default_project_table: ProjectConfig | None = Field(default=None) _doc: TOMLDocument path: ClassVar[Path] = Path(get_app_dir("algobattle")) / "config.toml" @@ -104,6 +104,8 @@ def init_file(cls) -> None: .add(toml_newline()) .append("general", general) .add(toml_newline()) + .append("default_project_table", table().append("results", "results")) + .add(toml_newline()) ) cls.path.write_text(dumps_toml(doc)) @@ -123,7 +125,7 @@ def save(self) -> None: @property def default_project_doc(self) -> TomlTable | None: """The default exec config for each problem.""" - exec: Any = self._doc.get("default_project_config", table().append("results", "results")) + exec: Any = self._doc.get("default_project_table", None) return exec @cached_property From 2f95645d3cd7ee19a78b58bdeeb56d950bb8733f Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 04:40:41 +0200 Subject: [PATCH 100/113] tighten run config settings --- algobattle/match.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/algobattle/match.py b/algobattle/match.py index 8638960b..43a0ad12 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -10,7 +10,7 @@ from typing_extensions import override from typing_extensions import TypedDict -from pydantic import AfterValidator, ByteSize, ConfigDict, Field, GetCoreSchemaHandler, ValidationInfo, model_validator +from pydantic import AfterValidator, ByteSize, ConfigDict, Field, GetCoreSchemaHandler, ValidationInfo, field_validator, model_validator from pydantic.types import PathType from pydantic_core import CoreSchema from pydantic_core.core_schema import no_info_after_validator_function, union_schema @@ -548,7 +548,7 @@ class RunConfig(BaseModel): timeout: WithNone[TimeDeltaFloat] = 20 """Timeout in seconds, or `false` for no timeout.""" - space: WithNone[ByteSizeInt] = None + space: WithNone[ByteSizeInt] = 4_000_000_000 """Maximum memory space available, or `false` for no limitation. Can be either an plain number of bytes like `30000` or a string including @@ -557,6 +557,15 @@ class RunConfig(BaseModel): cpus: int = 1 """Number of cpu cores available.""" + @field_validator("cpus") + @classmethod + def check_nonzero(cls, val: int) -> int: + """Checks that the number of available cpus is non-zero.""" + if not val: + raise ValueError("Number must be non-zero") + else: + return val + class MatchConfig(BaseModel): """Parameters determining the match execution. From 5afdcae4a7c9e941ea2b606b71362a321378937c Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 05:18:38 +0200 Subject: [PATCH 101/113] format --- algobattle/match.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/algobattle/match.py b/algobattle/match.py index 43a0ad12..a4497a65 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -10,7 +10,16 @@ from typing_extensions import override from typing_extensions import TypedDict -from pydantic import AfterValidator, ByteSize, ConfigDict, Field, GetCoreSchemaHandler, ValidationInfo, field_validator, model_validator +from pydantic import ( + AfterValidator, + ByteSize, + ConfigDict, + Field, + GetCoreSchemaHandler, + ValidationInfo, + field_validator, + model_validator, +) from pydantic.types import PathType from pydantic_core import CoreSchema from pydantic_core.core_schema import no_info_after_validator_function, union_schema From 9f27de7473cf3da5e5b96842f687c755ef915173 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 05:30:54 +0200 Subject: [PATCH 102/113] include command in result file name --- algobattle/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index b8967512..69e75d6d 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -182,7 +182,7 @@ def run_match( if save: res_string = result.model_dump_json(exclude_defaults=True) - out_path = config.project.results.joinpath(f"{timestamp()}.json") + out_path = config.project.results.joinpath(f"match-{timestamp()}.json") out_path.write_text(res_string) console.print("Saved match result to ", out_path) return result @@ -457,7 +457,7 @@ async def sol_builder() -> Solver: all_errors[team] = errors.model_dump(exclude_defaults=True) if all_errors: - err_path = config.project.results.joinpath(f"{timestamp()}.json") + err_path = config.project.results.joinpath(f"test-{timestamp()}.json") err_path.write_text(json.dumps(all_errors, indent=4)) console.print(f"You can find detailed error messages at {err_path}") From 082c121535a5cdd3a955f3d5e6de504bea98be0e Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 05:45:21 +0200 Subject: [PATCH 103/113] change arg name --- algobattle/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 69e75d6d..d0280020 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -388,13 +388,13 @@ class TestErrors(BaseModel): @app.command() def test( - folder: Annotated[Path, Argument(help="The project folder to use.")] = Path(), + project: Annotated[Path, Argument(help="The project folder to use.")] = Path(), ) -> None: """Tests whether the programs install successfully and run on dummy instances without crashing.""" - if not (folder.is_file() or folder.joinpath("algobattle.toml").is_file()): + if not (project.is_file() or project.joinpath("algobattle.toml").is_file()): console.print("[error]The folder does not contain an Algobattle project") raise Abort - config = AlgobattleConfig.from_file(folder) + config = AlgobattleConfig.from_file(project) problem = config.problem all_errors: dict[str, Any] = {} From c3febf301df026ba0ada76d3e9cf65b0566c2d86 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 06:03:46 +0200 Subject: [PATCH 104/113] change averaged default --- algobattle/battle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/battle.py b/algobattle/battle.py index 64bfeaa9..9f0d009a 100644 --- a/algobattle/battle.py +++ b/algobattle/battle.py @@ -469,7 +469,7 @@ class Config(Battle.Config): type: Literal["Averaged"] = "Averaged" - instance_size: int = 10 + instance_size: int = 25 """Instance size that will be fought at.""" num_fights: int = 10 """Number of iterations in each round.""" From 4e1e05eeefd1d7d367c1c07014efb8ce92468813 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 13:29:11 +0200 Subject: [PATCH 105/113] update docs --- docs/{tutorial => advanced}/battle_types.md | 65 ++- docs/advanced/config.md | 260 +++++++++ docs/advanced/docker.md | 234 ++++++++ docs/advanced/index.md | 4 + docs/css/termynal.css | 107 ---- docs/js/custom.js | 109 ---- docs/js/termynal.js | 264 --------- docs/src/interface_build.txt | 4 + docs/src/interface_match.txt | 27 + docs/src/pairsum_generator/Dockerfile | 5 - docs/src/pairsum_generator/main.py | 38 +- docs/src/pairsum_generator/start.py | 20 +- docs/src/pairsum_solution2.json | 3 - docs/src/pairsum_solver/Cargo.toml | 15 - docs/src/pairsum_solver/Dockerfile | 6 - docs/src/pairsum_solver/main.rs | 32 +- docs/tutorial/config.md | 208 -------- docs/tutorial/getting_started.md | 168 ++++++ docs/tutorial/index.md | 26 +- docs/tutorial/installation.md | 89 ++-- docs/tutorial/match.md | 204 +++---- docs/tutorial/overview.md | 119 ----- docs/tutorial/programs.md | 560 +++++++------------- docs/tutorial/summary.md | 8 + mkdocs.yml | 19 +- 25 files changed, 1174 insertions(+), 1420 deletions(-) rename docs/{tutorial => advanced}/battle_types.md (55%) create mode 100644 docs/advanced/config.md create mode 100644 docs/advanced/docker.md create mode 100644 docs/advanced/index.md delete mode 100644 docs/css/termynal.css delete mode 100644 docs/js/custom.js delete mode 100644 docs/js/termynal.js create mode 100644 docs/src/interface_build.txt create mode 100644 docs/src/interface_match.txt delete mode 100644 docs/src/pairsum_generator/Dockerfile delete mode 100644 docs/src/pairsum_solution2.json delete mode 100644 docs/src/pairsum_solver/Cargo.toml delete mode 100644 docs/src/pairsum_solver/Dockerfile delete mode 100644 docs/tutorial/config.md create mode 100644 docs/tutorial/getting_started.md delete mode 100644 docs/tutorial/overview.md create mode 100644 docs/tutorial/summary.md diff --git a/docs/tutorial/battle_types.md b/docs/advanced/battle_types.md similarity index 55% rename from docs/tutorial/battle_types.md rename to docs/advanced/battle_types.md index 71222220..f062b01c 100644 --- a/docs/tutorial/battle_types.md +++ b/docs/advanced/battle_types.md @@ -3,18 +3,17 @@ When Algobattle runs a match it executes several battles that score how good one team is at solving another team's instances. In this page we will take a look at how exactly this works by going through all battle types. -///note -Algobattle lets users create custom battle types. If you're looking for information to how the battle type you're using -works and can't find it here it is best you ask your course instructor about it. -/// +!!! note "Custom battle types" + Algobattle lets users create custom battle types. If you're looking for information to how the battle type you're + using works and can't find it here it's best you ask your course instructor about it. -# Iterated +## Iterated The basic idea behind iterated battles is very straightforward. Generally solving bigger instances is much harder than solving smaller ones. So we can judge how well a solver is doing by searching for the biggest instance size it can solve correctly. But we of course the only thing we care about isn't just whether a solver is able to solve some instance, but whether it is able to do so using a reasonable amount of system resources. -Algorithmically this means that we give each program some fixed amount of time, memory space, and cpu cores and then +Algorithmically this means that we give each program some fixed amount of time, memory space, and CPU cores and then try to find the largest size where the solver can still correctly solver instances generated for it. The easiest way to do this would be to just steadily increase the instance size one by one and stop when the solver fails. @@ -23,23 +22,67 @@ can easily solve Pairsum instances containing less than a few hundred numbers wi iterated battles take a more aggressive approach, they increase the instance size in larger (and growing) steps until the solver fails. For example, the progression of instance sizes might be 1, 2, 5, 14, 30, 55, 91, etc. This quickly gives us a rough idea that we then fine tune recursively. Say the biggest size the solver can tackle is 64, this first -series of fights would then succeed til 55 and fail at 91. The progression is then restarted at 55 going up, 56, 60, 64, +series of fights would then succeed until 55 and fail at 91. The progression is then restarted at 55 going up, 56, 60, 64, 73, etc. Here the solver fails at 73 which causes the battle to again reset the size to 65. The solver again fails at this size, which means that the battle has found the correct size since the solver succeeded at everything below 64 but not anything higher. -This system works very well for programs that have a very sharp cutoff between instance sizes they can solve easily and +This system works very well for programs that have a very sharp cut-off between instance sizes they can solve easily and ones they struggle with. On the other hand, it has trouble assessing ones that operate with random elements since they might fail very early due to bad luck. To smooth things out and provide a fair experience for many approaches the Iterated battle repeats this process of increasing the instance size several times and averages the reached sizes. -# Averaged +### Config + +The Iterated battle type uses the following keys in the `match.battle` table in addition to `#!toml type = "Iterated`: + +`rounds` +: Number of rounds that will be run and averaged. A _round_ is one sequence of fights until a size has been found +where the solver succeeds at all smaller sizes and fails at all bigger ones. Defaults to 5. + +`maximum_size` +: Maximum size that will be iterated to. Defaults to 50000. + +`exponent` +: An integer that determines how quickly the size increases. For example, an exponent of 2 results in a size sequence +of 1, 2, 6, 15, 31, etc. while an exponent of 3 leads to 1, 2, 9, 36, 100, 255, etc. Defaults to 2. + +`minimum_score` +: A float between 0 and 1 (inclusive) that is the minimum score a solver needs to achieve to "successfully" solve +an instance. Defaults to 1. + +### UI Data + +Iterated battles display two values to the UI: + +reached +: The biggest instance size reached in each round so far. + +cap +: The current cap on the instance size. + +## Averaged It can also be interesting to restrict generators and solvers to a very narrow set of possible instances and see what possibilities for optimization this opens up. In particular, instead of considering how good a solver is at dealing with instances of increasing size we can judge how well it is at solving instances of a single, fixed size. This -changes the thought process when writing the programs to favor more specialized approaches that try to tease out the +changes the thought process when writing the programs to favour more specialized approaches that try to tease out the best possible performance. -Averaged battles fix not only the program runtime, memory, and cpu access, but also the instance size. A series of +Averaged battles fix not only the program runtime, memory, and CPU access, but also the instance size. A series of fights is run with these parameters and their results averaged. + +### Config + +`instance_size` +: The instance size every fight in each match will be fought at. Defaults to 25. + +`num_fights` +: The number of fights that will be fought in each match. Defaults to 10. + +### UI Data + +Averaged battles display a single value to the UI: + +round +: The current round that is being fought. diff --git a/docs/advanced/config.md b/docs/advanced/config.md new file mode 100644 index 00000000..a436fc31 --- /dev/null +++ b/docs/advanced/config.md @@ -0,0 +1,260 @@ +# Settings + +You can configure Algobattle in a lot of different ways, so it does exactly what you want it to do + +!!! question "Unsure about TOML syntax?" + TOML syntax can be a bit confusing if you're unfamiliar with it, a full explanation of it can be found on + [the official site](https://toml.io/en/). + +## CLI config + +To globally configure how Algobattle behaves you can use the cli config file. Open it by running + +```console +algobattle config +``` + +It's a TOML document that contains two tables: + +`general` +: This contains general cli config options. Its keys are: + + `team_name` + : This is the team name used in the project config whenever you initialize a new project. It doesn't need to be + the same name you use in the Algobattle website. The default is for Algobattle to dynamically generate a fun + team name when you run the command. + + `install_mode` + : If a problem requires some dependencies Algobattle installs them during project initialization. If the mode is + `user` it will do so in user space, if it is `normal` it will install it globally instead. We recommend you use + `normal` if you're using an environment manager like Conda and `user` otherwise. In the default setting + Algobattle will explicitly ask you to set this the first time it is needed. + + `generator_language` and `solver_language` + : This sets the default language to use in newly initialized projects. You can always override this by passing them + explicitly when running the command. Defaults to `plain` for both. + +`default_project_table` +: This table contains default values you would like to use in the `project` table in project level config files. See + the [project config](#project-config) documentation for details on what you can specify here. Defaults to only + containing a `results` key set to the `results` path. + +## Project config files + +These are the files normally called `algobattle.toml` that live in every Algobattle project folder. They can get quite +big and offer a lot of room for customization. It's made up of several tables at keys `match`, `teams`, `problems`, +`project`, and `docker`: + +!!! info "Relative paths" + If any path in this config file is specified it will be interpreted as relative to the config file, not Algobattle's + current working directory. + +### `match` +: This specifies how each match is run. + !!! warning + Be careful not to accidentally change things in here and then develop your programs to work with those settings. + The match run on the Algobattle server will use the settings you got from your course instructors, so your + programs might break when making wrong assumptions about the match structure. + + `problem` + : The only mandatory key in this table. It must be the name of the problem that is to be used. Either the name of an + installed problem, or one imported dynamically. If the latter option is used you need to specify the problem file's + location in the `problems` table. + + `build_timeout` + : A timeout used for every program build. If a build does not complete within this limit it will be cancelled and + the team whose program it is excluded from the match. Can either be specified as a number which is interpreted as + seconds, a string in the `HH:MM:SS` format, or `#!toml false` to set no limit. Defaults to 10 minutes. + + `max_program_size` + : A limit on how big each program may be. Does not limit the memory it can use while running, but rather the disk + space used by it after it has been built. Can either be an integer which is interpreted as bytes, or a string with + a unit such as `500 MB` or `1.3gb`, or `#!toml false` to set no limit. Defaults to 4 GB. The + [Pydantic ByteSize docs](https://docs.pydantic.dev/latest/usage/types/bytesize/#using-bytesize) contain a full + explanation of possible formats. + + `strict_timeouts` + : Programs may run into their timeout after already having generated some output. This setting determines how these + cases are handled, if it's set to `#!toml true` exceeding the time limit is considered a completely unsuccessful + program execution and is treated similar to if it had crashed completely. If it is `#!toml false`, the program will + just be stopped after the allotted time and any solution it may have generated is treated as is. Defaults to + `#!toml false` + + `generator` and `solver` + : Both of these fields accept the same type of table. They specify parameters guiding each generator and solver + execution respectively. + + `timeout` + : A limit on the program runtime. Exact behaviour of what happens when it is exceeded depends on the + `strict_timeouts` setting. Can either be a number which is interpreted as seconds, a string in the `HH:MM:SS` + format, or `#!toml false` to set no limit. Defaults to 20 seconds. + + `space` + : Limits the amount of memory space the program has available during execution. Can either be an integer which + is interpreted as bytes, a string with a unit such as `500 MB` or `1.3gb`, or `#!toml false` to set no limit. + Defaults to no limit. The + [Pydantic ByteSize docs](https://docs.pydantic.dev/latest/usage/types/bytesize/#using-bytesize) contain a full + explanation of possible formats. + + `cpus` + : Sets the number of physical CPUs the program can use. Can be any non-zero integer. Defaults to 1. + + `battle` + : This is a table containing settings relevant to the battle type the match uses. Valid keys are documented at the + [battle type page](battle_types.md). A single key is shared by all battle types: + + `type` + : This key specifies which battle type to use. Must be the name of a currently installed battle type. Defaults + to `#!toml "Iterated"`. + +### `teams` +: This table tells Algobattle where it can find each team's programs. Keys are team names and values table with this +structure with both keys being mandatory: + + `generator` + : Path to the team's generator. + + `solver` + : Path to the team's solver. + +### `problems` +: Contains data specifying how to dynamically import problems. Keys are problem names and values tables like this: + + !!! note + This table is usually filled in by the course administrators, if you're a student you probably won't have to + worry about it. + + `location` + : Path to the problem module. Defaults to `problem.py`, but we recommend to always specify this to make it explicit. + + `dependencies` + : A list of [PEP 508](https://peps.python.org/pep-0508/) conformant dependency specification strings. These will be + installed during project initialization to make sure the problem can be run without issue. Defaults to an empty list. + +### `project` +: Contains various project settings. + !!! info "Feel free to customize this" + Even though some affect _how_ a match is run they will never change its result. Every student can change these to + best fit their development workflow regardless of which ones might be used in the server matches. + + `points` + : An integer specifying the maximum number of points a team can achieve during this match. How points are calculated + is explained in more detail [here](match.md#points-calculation). Defaults to 100. + + `parallel_battles` + : To speed up battle execution you can let Algobattle run multiple battles in parallel. Note that while programs can + not directly interact with each other, they might still end up interfering with other programs that are being run at + the same time by attempting to use the same CPU, memory, or disk resources as each other. You can use the `set_cpus` + option to mitigate this problem. Defaults to 1. + + `set_cpus` + : Many modern CPUs have different types of physical cores with different performance characteristics. To provide a + level playing field it may be good to limit Algobattle to only use certain cores for programs. To do this, specify + either a comma separated list of CPUs (the first is numbered 0) such as `0,1,3,5` or a range like `0-4`. Note that + the formatting is very important here, you can not mix the two styles, add any spaces, or similar. A full format + spec can be found on the [Docker site](https://docs.docker.com/config/containers/resource_constraints/). + + This option accepts either a single such string, or a list of them. If a list is provided each battle that is run + in parallel will use one of the provided set of cores. For example, if this option is `["0,1", "2-3", "4,5"]` and + there are two battles executed at the same time, the first would use the first two physical CPUs and the second the + next two. Defaults to no CPU limitation. + + `name_images` + : Whether to give the Docker images descriptive names. This is very useful during development, but can lead to + security risks in matches between competing teams. Because of this we recommend setting this to true `#!toml true` + if you're a student running Algobattle on your own machine, and `#!toml false` in matches on the Algobattle server. + Defaults to `#!toml true`. + + `cleanup_images` + : Whether to remove all Docker images after we're done using them. If set to `#!toml false` your system will be + kept a bit tidier, but you will also have much longer build times since images can no longer be cached. Defaults + to `#!toml false`. + + `results` + : Path to the folder where result files are saved. Each result file will be a json file with a name containing the + command that created it and the current timestamp. Defaults to `results` + +### `docker` +: Contains various advanced Docker settings that are passed through to the Docker daemon without influencing Algobattle +itself. You generally should not need to use these settings. If you are running into a problem you cannot solve without +them, we recommend first opening an issue on [our GitHub](https://github.com/Benezivas/algobattle/issues) to see if +we can add this functionality to Algobattle directly. + + !!! danger + Many of these settings are very complicated and have potentially disastrous consequences. We recommend not using + any of these settings unless you are absolutely sure what the ones you are modifying do. Improper Docker Daemon + configuration may not only break Algobattle but can give potential attackers root access to your host machine. + + `build` + : Table containing parameters passed to the docker build command. Further documentation can be found on the + [Docker build site](https://docs.docker.com/engine/reference/commandline/build/). + + `run` + : Table containing parameters passed to the docker run command. Further documentation can be found on the + [Docker run site](https://docs.docker.com/engine/reference/commandline/run/). + +## Algobattle subcommands + +You can also directly configure many things as command line arguments. Which ones are available depends on the subcommand + +### run + +This command runs a match using the current project config file. + +`path` +: A positional only argument that specifies where to find the project config file. May either point to a file directly, +or to the parent directory of one named `algobattle.toml`. Defaults to the current working directory. + +`--ui` / `--no-ui` +: Keyword only option that controls whether the match UI is displayed during execution. Defaults to `#!py True`. + +`--save` / `--no-save` +: Keyword only option that controls whether the match result is saved to disk after it's run. Defaults to `#!py True`. + +### init + +This command initializes a project folder. + +`target` +: Path to the folder to create the project data in. When initializing a new problem this defaults to a new subdirectory +of the current working directory named after the problem. If you're instead using an existing project config file it +defaults to the current directory. + +`--problem` / `-p` +: Specifies the problem to use for this project. Can either be missing to use an already existing project config, the +name of an installed problem, or the path to a problem spec file. Defaults to using an already existing project config. + +`--generator` / `-g` +`--solver` / `-s` +`--language` / `-l` +: Specifies what language template to use for the generator, solver, or both. You cannot specify both `--language` and +either one of the other two options. Can be one of the names of language templates supported by Algobattle. Uses the +defaults set in the [CLI config](#cli-config) (which defaults to `plain`). + +??? info "Language list" + The full list of languages template names is: + + - python + - rust + - c + - cpp + - csharp + - javascript + - typescript + - java + - go + +`--schemas` / `--no-schemas` +: Whether to also include the problem I/O json schemas in the `schemas` subdirectory. Defaults to `#!py False`. + +### test + +This runs a basic test checking whether the programs in a project build and run correctly. + +`project` +: Path to the Algobattle project to test. Can either point directly to a project config file, or to a folder containing +one called `algobattle.toml`. Defaults to the current working directory. + +### config + +Opens the CLI config file. Accepts no arguments.1 diff --git a/docs/advanced/docker.md b/docs/advanced/docker.md new file mode 100644 index 00000000..c34c64bc --- /dev/null +++ b/docs/advanced/docker.md @@ -0,0 +1,234 @@ +# Docker + +Algobattle uses Docker to run the programs students write. This lets us support students using any language, easily +restrict their time, space, and CPU usage, provide a safe environment, and much more. + +## Basic functionality + +If you haven't used Docker before, getting your head around what it's doing and what you need to do can be a bit +confusing. Luckily we do not need to understand most of its behind the scenes working and, the parts that we do need are +pretty straightforward. You can think of Docker as a virtual machine management tool, it lets you create _images_ that +basically are save files of an entire computer including the OS and everything else installed on it. We can then run +these as _containers_, independent virtual machines that start off from that save file and then run some code. + +Algobattle uses such images and containers to manage the students' programs. What we actually care about when receiving +e.g. the path to a generator is not all the source files and whatever else might be in that folder, but the Docker +image that can be built using it. + +Since containers run essentially as virtual machines, they are entirely separate from the host machines' OS. In +particular, they do not share a file system. This is why the programs do not see the host machines actual files and +have to read/write from the `/input` and `/output` directories. Algobattle creates the containers with special links +between the host machine's file system and these folders and then looks only at these directories. + +## Dockerfiles + +Dockerfiles are what Docker uses to create images. When Algobattle is told that there's a generator in `generator/`, it +will ask Docker to _build_ the Dockerfile in that folder. Docker then takes the file found at `generator/Dockerfile` +and interprets every line in it as a particular step in the build process. These steps are completed in order of their +occurrence in the Dockerfile. Once Docker has completed every step, the build is complete, and we get the finalized +image. This image will essentially look exactly like the virtual machine did after the last build step ran, plus some +special Docker metadata. + +The full specification of what Dockerfiles can contain is [here](https://docs.docker.com/engine/reference/builder/), but +most of it is not super relevant for us. The most important commands are listed here: + +#### The `#!Dockerfile FROM` statement + +The first line of every Dockerfile has to be a `#!Dockerfile FROM` statement, the most basic example is +`#!Dockerfile FROM scratch`. This line tells Docker what to base your image off of, `#!Dockerfile FROM scratch` +means that it starts with a completely empty file system. If we do that we need to first install an operating system, +configure it enough to be usable, and then install whatever we actually want to run. We can make our Dockerfiles much +simpler by using one of the already existing images in the [_Docker Hub_](https://hub.docker.com/) +in our `#!Dockerfile FROM` statement instead. Instead of starting with an empty file system we then start with the file +system of that image. + +All major operating systems have images containing a fresh installation of them on the Docker Hub. For example, +[here](https://hub.docker.com/_/alpine) is the official Alpine image, [here](https://hub.docker.com/_/ubuntu) is Ubuntu, +and [here](https://hub.docker.com/_/debian) is Debian. If you want your code to run in a clean environment with nothing +else you can use any of these as your base. + +!!! warning + In principle Docker can also run Windows OS's inside the containers, but this requires special setup on the host + machine. In particular, every image needs to then be a Windows image, there is no way to control both Linux and + Windows containers at the same time. We recommend course administrators configure Docker to run Linux containers + (this is the default) and inform students that they are required to use Linux in their images. + + Talk to your course administrators if you are a student and unsure about what OS to use. + +Since you want the container to execute some code you will most likely then need to install a compiler or runtime for +whatever language you're using. We can easily skip this intermediary step and instead base our image off of one that +already includes this. Most languages have officially published images that contain some Linux distro and an +installation of everything that compiler/interpreter needs to work. For example, [here](https://hub.docker.com/_/python) +is Python's and [here](https://hub.docker.com/_/rust) Rust's. + +Images on the Docker Hub can also be versioned using tags. For example, the official Python image has dozens of slightly +different versions that come with different OS's, Python versions, etc. If you want to use a specific tag you need to +list it in the `#!Dockerfile FROM` statement after a colon. For example, if your code needs Python 3.10 you can write +`#!Dockerfile FROM python:3.10`. + +!!! tip + Different languages use different schemes for tagging their images. Always check the official page on the + [Docker Hub](https://hub.docker.com/) to make sure you're getting the right version of everything. + +#### Changing the `#!Dockerfile WORKDIR` + +As the name suggests, `#!Dockerfile WORKDIR /some/dir` changes the current working directory. All subsequent commands +will be executed from `/some/dir`. Note that the path must be absolute. This also can also affect where the program +that runs when you start a container from the image if you change the working directory before the `#!Dockerfile CMD` +or `#!Dockerfile ENTRYPOINT` line. + +#### `#!Dockerfile COPY`ing files + +Now that we have a container that includes our language's runtime we also need to include our code and all other files +we may need. The `#!Dockerfile COPY` command does exactly this. For it, we just list the path to the file on the host +file system, and the path it should be at in the image. Our example has the generator code in a single file next to the +Dockerfile, so we can place it into the root directory of the image with `#!Dockerfile COPY main.py /`. Paths can be +absolute or relative, and you can specify multiple sources in a single line. You can also use a glob-like syntax to +match multiple specific files. + +??? example + All of these are valid `#!Dockerfile COPY` statements: + + - `#!Dockerfile COPY some_file.py .` results in `some_file.py` being placed in the current directory + - `#!Dockerfile COPY some_dir target_dir/` results in every file in `some_dir` and all its subfolders being placed + in `target_dir/`, effectively copying over the entire tree rooted at `some_dir` and rooting it at `target_dir` + - `#!Dockerfile COPY nested/location/source.txt .` copied the source file into the current directory + - `#!Dockerfile COPY multiple.py source.json files.txt single/target/dir/` copies both source files to the target + directory + - `#!Dockerfile COPY source.rs /absolute/target` copies the file into the target directory + - `#!Dockerfile COPY *.py /` copies all Python files in the current directory into the root of the image + +!!! warning "The build context" + You cannot `#!Dockerfile COPY` files outside the directory containing the Dockerfile. That is + `#!Dockerfile COPY ../../something ./` will not work. This is not a limitation of Algobattle but just a side effect + of how Docker works. + +!!! tip "trailing slashes" + Notice how we sometimes specify trailing slashes even though they're not strictly needed. This is to make sure that + Docker knows we are referring to a directory, not a file. If you just write `#!Dockerfile COPY something other` + and `something` is a file it will place it into the current directory and rename it `other`. If you want it to + instead keep the name and place it in the `other/` directory, you need to include the trailing slash. + +#### `#!Dockerfile RUN`ning commands + +You can use `#!Dockerfile RUN some shell command` to execute `#!shell some shell command` in a shell during the image +build step. This command will have access to everything that was copied into the image beforehand and anything that +previously ran commands created. Most often, this is used to install dependencies of your program. + +This statement has two forms, the first `#!Dockerfile RUN some shell command`, and the other +`#!Dockerfile RUN ["some", "shell", "command"]`. For our purposes they do largely the same thing, but their differences +are explained [here](https://docs.docker.com/engine/reference/builder/#run) + +#### Specifying the program `#!Dockerfile CMD` + +Lastly, the container that runs from your image needs to know what it should actually do. You can specify this with the +`#!Dockerfile CMD` statement. Its arguments form some shell command that is not executed during the build step, +but when the container starts. + +Similar to run this command also has the same two forms, and you can choose whichever you prefer, though the list style +syntax is usually preferred. They are explained in detail [here](https://docs.docker.com/engine/reference/builder/#cmd). + +## Tips and Tricks + +### Faster builds with better caching + +Building docker images can take quite a long time depending on what is happening in the build. When you're developing +your programs and keep making small changes to your code before rebuilding this can be incredibly annoying. Luckily +Docker implements a cache of so-called _layers_ for us. You can think of layers as basically being break points in +between every line in your Dockerfile. Let's look at an example: + +```Dockerfile +FROM python:3.11 + +WORKDIR /algobattle +COPY . ./ +RUN pip install . + +WORKDIR / +CMD [ "python", "-m", "generator" ] +``` + +The first layer is just the original Python base image, the next is the base image plus the change of the working +directory, then the base image plus the changed working directory, plus the copied files, etc. If you now build this +Dockerfile Docker will automatically cache every layer separately. Subsequent builds will then use these cached layers +up until the point where things have changed and thus need to be built again. + +The important part here is being aware of what causes Docker to invalidate caches, and make sure that it happens as +late in the Dockerfile as possible. `#!Dockerfile COPY` commands invalidate caches whenever the files you're copying +over have changed. This means that every time you make a code change to the above code you invalidate the cache used +for the `#!Dockerfile COPY` and all subsequent commands, which means that pip has to reinstall every dependency every +time you rebuild the image. To better cache your dependencies you can install them before you copy over your code: + +```Dockerfile +FROM python:3.11 + +WORKDIR /algobattle +COPY pyproject.toml ./ +RUN pip install . +COPY . ./ +RUN pip install . + +WORKDIR / +CMD [ "python", "-m", "generator" ] +``` + +This might look slower at first glance since it's doing a lot more, and it will be slightly slower during the first +build, but if you're using dependencies that take a bit to install this will be much faster in the long run. Obviously, +the same ideas apply to other languages. To make the best use of the cache, you want your `#!Dockerfile COPY` commands +to be as selective as possible and be executed as late as possible. + +!!! info "`#!Dockerfile RUN` and caching" + The `#!Dockerfile RUN` command never invalidates the cache! Even if you are running some command that e.g. pulls + from the web and the content of that download changes, Docker will not rerun it unless something before it created + a cache miss. This is great most of the time since we're downloading deterministic data like dependencies, but can + cause issues if you expect to dynamically update data. + +### Building images yourself + +Sometimes it's nice to build images yourself to debug them. You can find the full documentation on the +[Docker build page](https://docs.docker.com/engine/reference/commandline/build/), but the basics aren't as complicated +as they make it out to be! In its purest form you just run + +```console +docker build path/to/build/dir +``` + +With a path pointing to the directory containing the Dockerfile you want to build. This will then build the image and +display a detailed log including any error messages in the console. If you want to then refer back to the image you'll +have to use its ID, which can become quite annoying, so you probably want to tag the image when you build it: + +```console +docker build -t some_name path/to/build/dir +``` + +### Running containers yourself + +You will probably also want to run containers yourself. This command is very powerful and even more complicated, if +you're feeling brave you can check out the docs on the +[Docker run page](https://docs.docker.com/engine/reference/commandline/run/). The most common style of command you will +need is + +```console +docker run -ti some_name +``` + +This runs the container and then mirrors its stdin, stdout, and stderr to your console, effectively behaving as though +you've opened a terminal inside the running container. `some_name` needs to be the same name you gave the image when +you built it. + +!!! tip "Algobattle image names" + If you're using the `name_images` Algobattle setting (defaults to `#!toml true`) the images Algobattle creates will + be named like `algobattle_{team_name}_{program_type}`, so e.g. `algobattle_crows_generator` or + `algobattle_red_pandas_solver`. You can run these directly without having to build them yourself. + +Since the program expects the usual Algobattle input in the `/input` directory, which will be missing if you run it +yourself, the container will most likely just crash. What's more useful is to tell Docker to use some other command +when running the container. Like this: + +```console +docker run -ti some_name bash +``` + +This will run `some_name` but without executing the `#!Dockerfile CMD` command and running `bash` instead. So we +effectively just open a terminal inside the container and can then inspect the container, build artefacts, etc to debug +things. diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 00000000..f949179e --- /dev/null +++ b/docs/advanced/index.md @@ -0,0 +1,4 @@ +# Advanced Topics + +This section cover various topics that aren't really necessary to understand in depth and aren't covered in the tutorial. +It's not meant to be read as a single continuous narrative but rather as a reference guide to look up when you need to. diff --git a/docs/css/termynal.css b/docs/css/termynal.css deleted file mode 100644 index b215d7ef..00000000 --- a/docs/css/termynal.css +++ /dev/null @@ -1,107 +0,0 @@ -/** - * termynal.js - * - * @author Ines Montani - * @version 0.0.1 - * @license MIT - */ - - :root { - --color-bg: #252a33; - --color-text: #eee; - --color-text-subtle: #a2a2a2; -} - -[data-termynal] { - width: 750px; - max-width: 100%; - background: var(--color-bg); - color: var(--color-text); - font-size: 15px; - font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; - border-radius: 4px; - padding: 75px 45px 35px; - position: relative; - -webkit-box-sizing: border-box; - box-sizing: border-box; -} - -[data-termynal]:before { - content: ''; - position: absolute; - top: 15px; - left: 15px; - display: inline-block; - width: 15px; - height: 15px; - border-radius: 50%; - /* A little hack to display the window buttons in one pseudo element. */ - background: #d9515d; - -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; - box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; -} - -[data-termynal]:after { - content: 'bash'; - position: absolute; - color: var(--color-text-subtle); - top: 5px; - left: 0; - width: 100%; - text-align: center; -} - -a[data-terminal-control] { - text-align: right; - display: block; - color: #aebbff; -} - -[data-ty] { - display: block; - line-height: 2; -} - -[data-ty]:before { - /* Set up defaults and ensure empty lines are displayed. */ - content: ''; - display: inline-block; - vertical-align: middle; -} - -[data-ty="input"]:before, -[data-ty-prompt]:before { - margin-right: 0.75em; - color: var(--color-text-subtle); -} - -[data-ty="input"]:before { - content: '$'; -} - -[data-ty][data-ty-prompt]:before { - content: attr(data-ty-prompt); -} - -[data-ty-cursor]:after { - content: attr(data-ty-cursor); - font-family: monospace; - margin-left: 0.5em; - -webkit-animation: blink 1s infinite; - animation: blink 1s infinite; -} - - -/* Cursor animation */ - -@-webkit-keyframes blink { - 50% { - opacity: 0; - } -} - -@keyframes blink { - 50% { - opacity: 0; - } -} \ No newline at end of file diff --git a/docs/js/custom.js b/docs/js/custom.js deleted file mode 100644 index db81b006..00000000 --- a/docs/js/custom.js +++ /dev/null @@ -1,109 +0,0 @@ -function setupTermynal() { - document.querySelectorAll(".use-termynal").forEach(node => { - node.style.display = "block"; - new Termynal(node, { - lineDelay: 500 - }); - }); - const progressLiteralStart = "---> 100%"; - const promptLiteralStart = "$ "; - const customPromptLiteralStart = "# "; - const termynalActivateClass = "termy"; - let termynals = []; - - function createTermynals() { - document - .querySelectorAll(`.${termynalActivateClass} .highlight`) - .forEach(node => { - const text = node.textContent; - const lines = text.split("\n"); - const useLines = []; - let buffer = []; - function saveBuffer() { - if (buffer.length) { - let isBlankSpace = true; - buffer.forEach(line => { - if (line) { - isBlankSpace = false; - } - }); - dataValue = {}; - if (isBlankSpace) { - dataValue["delay"] = 0; - } - if (buffer[buffer.length - 1] === "") { - // A last single
won't have effect - // so put an additional one - buffer.push(""); - } - const bufferValue = buffer.join("
"); - dataValue["value"] = bufferValue; - useLines.push(dataValue); - buffer = []; - } - } - for (let line of lines) { - if (line === progressLiteralStart) { - saveBuffer(); - useLines.push({ - type: "progress" - }); - } else if (line.startsWith(promptLiteralStart)) { - saveBuffer(); - const value = line.replace(promptLiteralStart, "").trimEnd(); - useLines.push({ - type: "input", - value: value - }); - } else if (line.startsWith("// ")) { - saveBuffer(); - const value = "💬 " + line.replace("// ", "").trimEnd(); - useLines.push({ - value: value, - class: "termynal-comment", - delay: 0 - }); - } else if (line.startsWith(customPromptLiteralStart)) { - saveBuffer(); - const promptStart = line.indexOf(promptLiteralStart); - if (promptStart === -1) { - console.error("Custom prompt found but no end delimiter", line) - } - const prompt = line.slice(0, promptStart).replace(customPromptLiteralStart, "") - let value = line.slice(promptStart + promptLiteralStart.length); - useLines.push({ - type: "input", - value: value, - prompt: prompt - }); - } else { - buffer.push(line); - } - } - saveBuffer(); - const div = document.createElement("div"); - node.replaceWith(div); - const termynal = new Termynal(div, { - lineData: useLines, - noInit: true, - lineDelay: 500 - }); - termynals.push(termynal); - }); - } - - function loadVisibleTermynals() { - termynals = termynals.filter(termynal => { - if (termynal.container.getBoundingClientRect().top - innerHeight <= 0) { - termynal.init(); - return false; - } - return true; - }); - } - window.addEventListener("scroll", loadVisibleTermynals); - createTermynals(); - loadVisibleTermynals(); -} - -setupTermynal(); diff --git a/docs/js/termynal.js b/docs/js/termynal.js deleted file mode 100644 index 1a307c4d..00000000 --- a/docs/js/termynal.js +++ /dev/null @@ -1,264 +0,0 @@ -/** - * termynal.js - * A lightweight, modern and extensible animated terminal window, using - * async/await. - * - * @author Ines Montani - * @version 0.0.1 - * @license MIT - */ - -'use strict'; - -/** Generate a terminal widget. */ -class Termynal { - /** - * Construct the widget's settings. - * @param {(string|Node)=} container - Query selector or container element. - * @param {Object=} options - Custom settings. - * @param {string} options.prefix - Prefix to use for data attributes. - * @param {number} options.startDelay - Delay before animation, in ms. - * @param {number} options.typeDelay - Delay between each typed character, in ms. - * @param {number} options.lineDelay - Delay between each line, in ms. - * @param {number} options.progressLength - Number of characters displayed as progress bar. - * @param {string} options.progressChar – Character to use for progress bar, defaults to █. - * @param {number} options.progressPercent - Max percent of progress. - * @param {string} options.cursor – Character to use for cursor, defaults to ▋. - * @param {Object[]} lineData - Dynamically loaded line data objects. - * @param {boolean} options.noInit - Don't initialise the animation. - */ - constructor(container = '#termynal', options = {}) { - this.container = (typeof container === 'string') ? document.querySelector(container) : container; - this.pfx = `data-${options.prefix || 'ty'}`; - this.originalStartDelay = this.startDelay = options.startDelay - || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600; - this.originalTypeDelay = this.typeDelay = options.typeDelay - || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90; - this.originalLineDelay = this.lineDelay = options.lineDelay - || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500; - this.progressLength = options.progressLength - || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40; - this.progressChar = options.progressChar - || this.container.getAttribute(`${this.pfx}-progressChar`) || '█'; - this.progressPercent = options.progressPercent - || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100; - this.cursor = options.cursor - || this.container.getAttribute(`${this.pfx}-cursor`) || '▋'; - this.lineData = this.lineDataToElements(options.lineData || []); - this.loadLines() - if (!options.noInit) this.init() - } - - loadLines() { - // Load all the lines and create the container so that the size is fixed - // Otherwise it would be changing and the user viewport would be constantly - // moving as they scroll - const finish = this.generateFinish() - finish.style.visibility = 'hidden' - this.container.appendChild(finish) - // Appends dynamically loaded lines to existing line elements. - this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData); - for (let line of this.lines) { - line.style.visibility = 'hidden' - this.container.appendChild(line) - } - const restart = this.generateRestart() - restart.style.visibility = 'hidden' - this.container.appendChild(restart) - this.container.setAttribute('data-termynal', ''); - } - - /** - * Initialise the widget, get lines, clear container and start animation. - */ - init() { - /** - * Calculates width and height of Termynal container. - * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS. - */ - const containerStyle = getComputedStyle(this.container); - this.container.style.width = containerStyle.width !== '0px' ? - containerStyle.width : undefined; - this.container.style.minHeight = containerStyle.height !== '0px' ? - containerStyle.height : undefined; - - this.container.setAttribute('data-termynal', ''); - this.container.innerHTML = ''; - for (let line of this.lines) { - line.style.visibility = 'visible' - } - this.start(); - } - - /** - * Start the animation and rener the lines depending on their data attributes. - */ - async start() { - this.addFinish() - await this._wait(this.startDelay); - - for (let line of this.lines) { - const type = line.getAttribute(this.pfx); - const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay; - - if (type == 'input') { - line.setAttribute(`${this.pfx}-cursor`, this.cursor); - await this.type(line); - await this._wait(delay); - } - - else if (type == 'progress') { - await this.progress(line); - await this._wait(delay); - } - - else { - this.container.appendChild(line); - await this._wait(delay); - } - - line.removeAttribute(`${this.pfx}-cursor`); - } - this.addRestart() - this.finishElement.style.visibility = 'hidden' - this.lineDelay = this.originalLineDelay - this.typeDelay = this.originalTypeDelay - this.startDelay = this.originalStartDelay - } - - generateRestart() { - const restart = document.createElement('a') - restart.onclick = (e) => { - e.preventDefault() - this.container.innerHTML = '' - this.init() - } - restart.href = '#' - restart.setAttribute('data-terminal-control', '') - restart.innerHTML = "restart ↻" - return restart - } - - generateFinish() { - const finish = document.createElement('a') - finish.onclick = (e) => { - e.preventDefault() - this.lineDelay = 0 - this.typeDelay = 0 - this.startDelay = 0 - } - finish.href = '#' - finish.setAttribute('data-terminal-control', '') - finish.innerHTML = "fast →" - this.finishElement = finish - return finish - } - - addRestart() { - const restart = this.generateRestart() - this.container.appendChild(restart) - } - - addFinish() { - const finish = this.generateFinish() - this.container.appendChild(finish) - } - - /** - * Animate a typed line. - * @param {Node} line - The line element to render. - */ - async type(line) { - const chars = [...line.textContent]; - line.textContent = ''; - this.container.appendChild(line); - - for (let char of chars) { - const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay; - await this._wait(delay); - line.textContent += char; - } - } - - /** - * Animate a progress bar. - * @param {Node} line - The line element to render. - */ - async progress(line) { - const progressLength = line.getAttribute(`${this.pfx}-progressLength`) - || this.progressLength; - const progressChar = line.getAttribute(`${this.pfx}-progressChar`) - || this.progressChar; - const chars = progressChar.repeat(progressLength); - const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`) - || this.progressPercent; - line.textContent = ''; - this.container.appendChild(line); - - for (let i = 1; i < chars.length + 1; i++) { - await this._wait(this.typeDelay); - const percent = Math.round(i / chars.length * 100); - line.textContent = `${chars.slice(0, i)} ${percent}%`; - if (percent>progressPercent) { - break; - } - } - } - - /** - * Helper function for animation delays, called with `await`. - * @param {number} time - Timeout, in ms. - */ - _wait(time) { - return new Promise(resolve => setTimeout(resolve, time)); - } - - /** - * Converts line data objects into line elements. - * - * @param {Object[]} lineData - Dynamically loaded lines. - * @param {Object} line - Line data object. - * @returns {Element[]} - Array of line elements. - */ - lineDataToElements(lineData) { - return lineData.map(line => { - let div = document.createElement('div'); - div.innerHTML = `${line.value || ''}`; - - return div.firstElementChild; - }); - } - - /** - * Helper function for generating attributes string. - * - * @param {Object} line - Line data object. - * @returns {string} - String of attributes. - */ - _attributes(line) { - let attrs = ''; - for (let prop in line) { - // Custom add class - if (prop === 'class') { - attrs += ` class=${line[prop]} ` - continue - } - if (prop === 'type') { - attrs += `${this.pfx}="${line[prop]}" ` - } else if (prop !== 'value') { - attrs += `${this.pfx}-${prop}="${line[prop]}" ` - } - } - - return attrs; - } -} - -/** -* HTML API: If current script has container(s) specified, initialise Termynal. -*/ -if (document.currentScript.hasAttribute('data-termynal-container')) { - const containers = document.currentScript.getAttribute('data-termynal-container'); - containers.split('|') - .forEach(container => new Termynal(container)) -} \ No newline at end of file diff --git a/docs/src/interface_build.txt b/docs/src/interface_build.txt new file mode 100644 index 00000000..b079a1f2 --- /dev/null +++ b/docs/src/interface_build.txt @@ -0,0 +1,4 @@ +╭────────────────────── Algobattle 4.0.0 ───────────────────────╮ +│ Building programs ━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━━━━━━━━━━━ 1/2 │ +│ Red Pandas ⠴ ━━━━━╺━━━━ 0:00:15 │ +╰───────────────────────────────────────────────────────────────╯ diff --git a/docs/src/interface_match.txt b/docs/src/interface_match.txt new file mode 100644 index 00000000..c222ed44 --- /dev/null +++ b/docs/src/interface_match.txt @@ -0,0 +1,27 @@ +╭────────────────────────────── Algobattle 4.0.0 ──────────────────────────────╮ +│ │ +│ Match overview │ +│ ┏━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┓ │ +│ ┃ Generating ┃ Solving ┃ Result ┃ │ +│ ┡━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━┩ │ +│ │ Red Pandas │ Red Pandas │ 290 │ │ +│ └────────────┴────────────┴────────┘ │ +│ │ +│ ───────────────────────── Red Pandas vs Red Pandas ───────────────────────── │ +│ │ +│ ╭────── Current Fight ───────╮ ╭─ Battle Data ──╮ │ +│ │ Max size: 294 │ │ reached: [290] │ │ +│ │ Generator 0.5 / 20 ✔ │ │ cap: 389 │ │ +│ │ Solver ⠙ 1.1 / 20 │ ╰────────────────╯ │ +│ ╰────────────────────────────╯ │ +│ Most recent fights │ +│ ┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ +│ ┃ Fight ┃ Max size ┃ Score ┃ Detail ┃ │ +│ ┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ +│ │ 12 │ 290 │ 100.0% │ Runtimes: gen 0.1s, sol 1.7s │ │ +│ │ 11 │ 389 │ 0.0% │ Solver failed: The json file does not exist. │ │ +│ │ 10 │ 289 │ 100.0% │ Runtimes: gen 0.1s, sol 2.0s │ │ +│ │ 9 │ 208 │ 100.0% │ Runtimes: gen 0.1s, sol 0.2s │ │ +│ │ 8 │ 144 │ 100.0% │ Runtimes: gen 0.1s, sol 0.4s │ │ +│ └───────┴──────────┴────────┴──────────────────────────────────────────────┘ │ +╰──────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/docs/src/pairsum_generator/Dockerfile b/docs/src/pairsum_generator/Dockerfile deleted file mode 100644 index 133a43af..00000000 --- a/docs/src/pairsum_generator/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM python - -COPY main.py / - -ENTRYPOINT python main.py diff --git a/docs/src/pairsum_generator/main.py b/docs/src/pairsum_generator/main.py index ccf499fa..74ee9bf9 100644 --- a/docs/src/pairsum_generator/main.py +++ b/docs/src/pairsum_generator/main.py @@ -1,27 +1,29 @@ +"""Main module, will be run as the generator.""" import json +from pathlib import Path from random import randrange, sample -with open("input/max_size.txt") as file: # (1)! - size = int(file.read()) -numbers = [randrange(2**63 - 1) for _ in range(size - 4)] # (2)! +max_size = int(Path("/input/max_size.txt").read_text()) -a, b = randrange(2**63 - 1), randrange(2**63 - 1) # (4)! + +numbers = [randrange(2**63 - 1) for _ in range(max_size - 4)] # (1)! +instance = { + "numbers": numbers, +} + +a, b = randrange(2**63 - 1), randrange(2**63 - 1) # (2)! c = randrange(min(a + b, 2**63 - 1)) d = a + b - c -solution = [a, b, c, d] -solution_indices = sorted(sample(range(size), 4)) -for index, number in zip(solution_indices, solution): + +indices = sorted(sample(range(max_size), 4)) # (3)! +for index, number in zip(indices, [a, b, c, d]): numbers.insert(index, number) -with open("output/instance.json", "x") as file: # (3)! - instance = { - "numbers": numbers, - } - json.dump(instance, file) - -with open("output/solution.json", "x") as file: # (5)! - solution = { - "indices": solution_indices, - } - json.dump(solution, file) +solution = { + "indices": indices, +} + + +Path("/output/instance.json").write_text(json.dumps(instance)) +Path("/output/solution.json").write_text(json.dumps(solution)) diff --git a/docs/src/pairsum_generator/start.py b/docs/src/pairsum_generator/start.py index cbc5c91a..dcd8634b 100644 --- a/docs/src/pairsum_generator/start.py +++ b/docs/src/pairsum_generator/start.py @@ -1,13 +1,17 @@ +"""Main module, will be run as the generator.""" import json +from pathlib import Path from random import randrange -with open("input/max_size.txt") as file: # (1)! - size = int(file.read()) -numbers = [randrange(2**63 - 1) for _ in range(size - 4)] # (2)! +max_size = int(Path("/input/max_size.txt").read_text()) -with open("output/instance.json", "x") as file: # (3)! - instance = { - "numbers": numbers, - } - json.dump(instance, file) +numbers = [randrange(2**63 - 1) for _ in range(max_size)] # (1)! +instance = { + "numbers": numbers, # (2)! +} +solution = ... + + +Path("/output/instance.json").write_text(json.dumps(instance)) +Path("/output/solution.json").write_text(json.dumps(solution)) diff --git a/docs/src/pairsum_solution2.json b/docs/src/pairsum_solution2.json deleted file mode 100644 index 0de1f4e2..00000000 --- a/docs/src/pairsum_solution2.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "indices": [0, 4, 1, 3] -} \ No newline at end of file diff --git a/docs/src/pairsum_solver/Cargo.toml b/docs/src/pairsum_solver/Cargo.toml deleted file mode 100644 index b9675ac6..00000000 --- a/docs/src/pairsum_solver/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "solver" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -itertools = "0.10.5" -serde = { version = "1.0.163", features = ["derive"] } -serde_json = "1.0.96" - -[[bin]] -name = "solver" -path = "main.rs" diff --git a/docs/src/pairsum_solver/Dockerfile b/docs/src/pairsum_solver/Dockerfile deleted file mode 100644 index f60fc334..00000000 --- a/docs/src/pairsum_solver/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM rust:1.69 - -COPY . ~/solver -RUN [ "cargo", "install", "--path", "~/solver" ] - -ENTRYPOINT [ "solver" ] diff --git a/docs/src/pairsum_solver/main.rs b/docs/src/pairsum_solver/main.rs index 5f792551..82e16b94 100644 --- a/docs/src/pairsum_solver/main.rs +++ b/docs/src/pairsum_solver/main.rs @@ -1,31 +1,35 @@ +// Main module, will be run as the solver + +use std::fs; +use std::error::Error; +use serde_json::{to_string, from_str}; use itertools::Itertools; -use serde::Deserialize; -use serde_json::{from_reader, json, to_writer}; -use std::fs::File; -use std::io::{BufReader, BufWriter, Write}; +use serde::{Deserialize, Serialize}; #[derive(Deserialize)] // (1)! struct Instance { numbers: Vec, } -fn main() -> Result<(), std::io::Error> { - let file = File::open("input/instance.json")?; // (2)! - let parsed: Instance = from_reader(BufReader::new(file))?; - let numbers = parsed.numbers; +#[derive(Serialize)] // (2)! +struct Solution { + indices: Vec +} + + +fn main() -> Result<(), Box> { + let instance: Instance = from_str(&fs::read_to_string("/input/instance.json")?)?; + let numbers = instance.numbers; for indices in (0..numbers.len()).combinations(4) { // (3)! let first = numbers[indices[0]] + numbers[indices[1]]; let second = numbers[indices[2]] + numbers[indices[3]]; if first == second { // (4)! - let solution = json!({ "indices": indices }); - let file = File::create("output/solution.json")?; - let mut writer = BufWriter::new(file); - to_writer(&mut writer, &solution)?; - writer.flush()?; + let solution = Solution {indices: indices}; + fs::write("/output/solution.json", to_string(&solution)?)?; return Ok(()); } } - Ok(()) + unreachable!() } diff --git a/docs/tutorial/config.md b/docs/tutorial/config.md deleted file mode 100644 index d2e9141d..00000000 --- a/docs/tutorial/config.md +++ /dev/null @@ -1,208 +0,0 @@ -# Configuration - -So far we've only discussed the default settings for everything, but Algobattle offers many different ways of -customizing exactly how matches are run. - -## Command line options - -The command line interface lets you specify a few things directly in the terminal. These mainly just let you specify -how the match should behave as a cli program, the actual match config is done through config files. - -///tip -Running `algobattle --help` will bring up a short description of each of these inside your terminal. -/// - -`path` - -: This is the only positional argument. It should either be the path to a config file or one to a directory containing -one that is called `algobattle.toml`. - -`--silent` / `-s` - -: Setting this will hide the match output in the terminal. - -`--result` / `-r` - -: This accepts a path to some directory. If it is set the match will be saved as a json file in that directory. It -includes a lot of useful info that is not displayed to the terminal during execution such as error messages from -programs that failed. - -## Config files - -Config files are toml files that Algobattle uses to fine tune how a match is run. All settings in it are entirely -optional with commonly used default values. Files that aren't properly formatted toml, or ones that have the wrong -layout of keys/values lead to the match being stopped with an appropriate error message. - -///question | Unsure about toml syntax? -Toml syntax can be a bit confusing if you're unfamiliar with it, a full explanation of it can be found on -[the official site](https://toml.io/en/). -/// - -An example config file filled with default values looks like this: - -/// example - -```toml -{!> algobattle.toml !} -``` - -/// - -### Teams - -The teams table contains keys that are each team's name. Values are tables containing paths to the generators and -solvers. - -/// example - -```toml -[teams.cats] -generator = "cats/generator" -solver = "cats/solver" - -[teams.dogs] -generator = "dogs/generator" -solver = "dogs/solver" -``` - -/// - -### Match - -The match table contains settings that specify what the match is like. These can drastically change what happens during -execution and what the result looks like. Because of this, students should use the same match settings as are used during -in the tournament. - -`problem` - -: The problem this match is about. Can either be specified as the name of an installed problem, or a path to a -file containing one. - -`build_timeout` - -: Time limit each program's image has to build, or `#!toml false` for no limit. Can either be a number of seconds or a -string in `HH:MM:SS` format. - -`strict_timeouts` - -: Programs may run into their timeout despite already having generated their output. For example, a solver might try -to incrementally improve the solution it has found. This setting determines how these cases are handled, if it's set -to `#!toml true` trying to exceed the time limit is considered a completely unsuccessful program execution and -is treated similar to if it had crashed completely. If it is `#!toml false`, the program will just be halted after -the allotted time and any solution it may have generated is treated as is. - -`image_size` - -: Limit the maximum size a Docker image may be, or `#!toml false` to allow arbitrarily large programs. Note that this -limits the disk space each image may take up, not the memory used during program execution. Can be specified as -either a number of bytes or a string with a unit such as `#!toml "2.5 GB"`. - -`generator` / `solver` - -: Both of these fields accept the same type of dictionary. It can contain `timeout`, `space`, and `cpus` keys that -limit the corresponding resource access to the generator and solver programs. Timeouts are specified in the same -format as `build_timeout` and memory space limits the same as `image_size`. Cpus limit the number of physical cpu -cores the program can use and has to be an integer. - -### Battle - -This contains the setting specifying what battle type to use and further options for each battle type. Each type can -specify its own settings and the available battle types are user extensible. Here we list the settings used by the -included types. Their full behavior is documented at the [battle types page](battle_types.md). - -`type` - -: Selects the battle type the match uses. Must be the name of an installed battle type, by default these are -`Iterated` and `Averaged` but more can be installed. - -#### Iterated - -`rounds` - -: Number of rounds that will be run and averaged. A _round_ is one sequence of fights until a size has been found -where the solver succeeds at all smaller sizes and fails at all bigger ones. - -`maximum_size` - -: Maximum size that will be iterated to. - -`exponent` - -: An integer that determines how quickly the size increases. For example, an exponent of 2 results in a size sequence -of 1, 2, 6, 15, 31, etc. while an exponent of 3 leads to 1, 2, 9, 36, 100, 255, etc. - -`minimum_score` - -: A float between 0 and 1 (inclusive) that is the minimum score a solver needs to achieve to "successfully" solve -an instance. - -#### Averaged - -`instance_size` - -: The instance size every match will be fought at. - -`num_fights` - -: The number of fights that will be fought in each match. - -### Execution - -These are settings that purely determine _how_ a match is fought. Students can freely change these without creating -any differences in how their code runs locally and in tournaments. - -`points` - -: An integer specifying the maximum number of points a team can achieve during this match. How points are calculated -is explained in more detail [here](match.md#points-calculation). The point total for each team will be displayed in -the terminal after the match. - -`parallel_battles` - -: To speed up battle execution you can let Algobattle run multiple battles in parallel. Note that while programs can -not directly interact with each other, they might still end up interfering with other programs that are being run at -the same time. In particular, they might attempt to use the same cpu, memory, or disk resources as another program -being run at the same time. You can use the `set_cpus` option to mitigate this risk. - -`set_cpus` - -: Similar to the Docker `--cpuset-cpus` option documented -[here](https://docs.docker.com/config/containers/resource_constraints/). Many modern cpus have different types of -physical cores with different performance characteristics. To provide a level playing field it can be good to limit -Algobattle to only use certain cores. To do this, specify either a comma separated list of cores (the first is -numbered 0) such as `0,1,3,5` or a range like `0-4`. Note that the formatting is very important here, you can not -mix the two styles or add any spaces or similar. - - This option accepts either a single such string, or a list of them. If a list is provided each battle that is run - in parallel will use one of the provided set of cores. For example, if this option is `["0,1", "2-3", "4,5"]` and - there are two battles executed at the same time, the first would use the first two physical cpus and the second the - next two. - -`mode` - -: Either `"tournament"` or `"testing"`. When set to tournament the docker containers are not given tags and are -cleaned up after the match to prevent potential exploits. Using the testing mode often is nicer since it lets Docker -use the build cache and thus massively speeds up development. - -### Docker - -The docker table contains settings that are passed through to the Docker daemon without influencing Algobattle itself. -You generally should not need to use these settings. If you are running into a problem you cannot solve without them, -we recommend first opening a discussion on [our GitHub](https://github.com/Benezivas/algobattle/discussions) to see if -we can add this functionality to Algobattle directly. - -///danger -Many of these settings are very complicated and have potentially disasterous consequences. We recommend not using any of -these settings unless you are absolutely sure what the ones you are modifying do. Improper Docker Daemon configuration -may not only break Algobattle but can give attackers root access to your host machine. -/// - -`build` - -: Table containing parameters passed to the docker build command. Further documentation can be found on -[the Docker build site](https://docs.docker.com/engine/reference/commandline/build/). - -`run` - -: Table containing parameters passed to the docker run command. Further documentation can be found on -[the Docker run site](https://docs.docker.com/engine/reference/commandline/run/). diff --git a/docs/tutorial/getting_started.md b/docs/tutorial/getting_started.md new file mode 100644 index 00000000..52f6f234 --- /dev/null +++ b/docs/tutorial/getting_started.md @@ -0,0 +1,168 @@ +# Getting Started + +## Getting a problem + +The idea behind the Algobattle framework is that course instructors will come up with some problem (in the theoretical +computer science meaning) that students will then write code to solve. The Algobattle program can then take the +problem definition and the code from all the student teams and score how good each team is at solving the problem. + +///tip +Now's a great time to remember to activate the Python environment you installed Algobattle into +(`conda activate algobattle`). +/// + +### Problem spec files + +The most common way your instructors will give you problem definitions is by uploading them to your Algobattle website. +On the specific problem's page you can then download an Algobattle problem spec file with the `.algo` extension. This +file contains all the information we need. + +///info | A peek behind the curtain +Despite their very fancy looking extension, `.algo` files really just zip files with a few files inside. Algobattle uses +these to set up your project folders for you, but it doesn't handle things very well if you feed it data that doesn't +have that structure, so the file extension is there to remind you that you're not meant to process these files manually. +If you're curious you can unzip them and take a look inside yourself. +/// + +### Installed problems + +Another way is to install a Python package that provides one or more problems. For example, our +[Algobattle Problem](https://github.com/Benezivas/algobattle-problems) package which contains a selection of basic +problems. We can download it and install it with pip after navigating into its directory + +```console +pip install . +``` + +## Setting up the workspace + +Now we can set up our dev environment where we write the code to solve the problem. I'll be showing you some folder +structures throughout but obviously the exact structure and names aren't mandatory. Let's start with a base folder which +should probably contain a `.git` folder and not much else. + +With the console in our base folder we can use Algobattle to initialize our project for us, you can use either a +problem spec or an installed problem for this. + +/// tab | Problem spec +```console +algobattle init -p path/to/spec/file.algo +``` +/// + +/// tab | Installed problem +```console +algobattle init -p "Problem Name" +``` +/// + +/// note +This tutorial will use the Pairsum problem as an example, if you're following along your project folder will look +slightly different depending on which problem you chose and what your course instructors decided to include with it. +/// + +Once this has run the folder should look something like this + +/// tab | Problem spec +``` { .sh .no-copy } +. +└─ Pairsum + ├─ generator/ + │ └─ Dockerfile + ├─ results/ + ├─ solver/ + │ └─ Dockerfile + ├─ .gitignore + ├─ algobattle.toml + ├─ description.md # this file may be missing, don't worry if it is! + └─ problem.py +``` +/// + +/// tab | Installed problem +``` { .sh .no-copy } +. +└─ Pairsum + ├─ generator/ + │ └─ Dockerfile + ├─ results/ + ├─ solver/ + │ └─ Dockerfile + ├─ .gitignore + └─ algobattle.toml +``` +/// + +What Algobattle has done is make a new project directory specific to this problem we're working with, and then +initialized all the required files and folders in it. Let's navigate into it for the rest of the tutorial. + +```console +cd Pairsum +``` + +## The `algobattle.toml` config file + +Project level configuration is done inside the `algobattle.toml` file so let's take a look at what's in there already. + +/// tab | Problem spec +```toml +[match] +problem = "Pairsum" +# there might be more settings here + +[problems."Pairsum"] +location = "problem.py" + +[teams."Red Pandas"] +generator = "generator" +solver = "solver" + +[project] +results = "results" +``` +/// + +/// tab | Installed problem +```toml +[match] +problem = "Pairsum" + +[teams."Red Pandas"] +generator = "generator" +solver = "solver" + +[project] +results = "results" +``` +/// + +The config file is split into a few tables, `match` specifies exactly what each Algobattle match is going to look like. +This means that you will probably never want to change things in there since you want to develop your programs for the +same conditions they're going to see during the scored matches run on the server. Feel free to play with the `teams`, +`problems`, and `project` tables as much as you want, nothing in them affects the structure of the match or anything +on the server. In particular, the team name used here doesn't need to match the one used on your Algobattle website. +The filled in settings so far all just are paths to where Algobattle can find certain files or folders. There's a lot +more things you can configure, but we're happy with the default values for now. + +/// tip +If you're curious what exactly everything in here means you can read the [config docs](/advanced/config.md). But for +now we recommend staying here since things will be much clearer after you're familiar with things here. +/// + +## The `problem.py` file + +///note +This will only exist if you used a problem spec file. +/// + +This is what Algobattle uses as the problem definition. Once you're familiar with the way Algobattle does things +you can cross-reference this to see what exactly your code needs to do. But it's also not directly meant to be +human-readable and easily understandable, in particular if you're not familiar with Python and Pydantic. + +## The `description.*` file + +///note +This will only exist if you used a problem spec file and your course instructors included it. +/// + +This is the version of the problem definition that's more fun to read. It can be whatever your course instructors +wanted to include, but most commonly is a Markdown or Pdf file. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index ba884c17..ab2dc96a 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -1,29 +1,27 @@ # Tutorial The Algobattle tutorial goes over everything needed to understand how the package works and the most common ways to -use it. It's aimed at students participating in a course and course instructor that want to learn how to work with the -package. +use it. It's aimed at students participating in a course and course instructors that want to learn how to work with the +framework. The tutorial pages build on each other and are best read in sequence. It assumes almost no prerequisite knowledge of specific topics, but an understanding of basic theoretical computer science ideas like algorithmic problems and of the Python language will make things a lot easier. -# Student quick start +## Quick overview -If all you want is the fastest way to get started writing code and running things, this is for you. Every step here -links to its more detailed explanation in the full tutorial. If you are already familiar with Docker and Python this is -all you need to do: +If you're dying to get started coding then the full tutorial might be a bit long for you. Here's all the steps you need +to get going, each also linking to corresponding part of the in depth tutorial. -1. [Install Python](installation.md#installing-python) +1. [Install everything we need](installation.md#installing-python) -2. [Install Docker](installation.md#installing-docker) +2. Download the [problem spec file](getting_started.md#problem-spec-files) your course instructors gave you -3. [Install the Algobattle package](installation.md#installing-algobattle) +3. [Run `algobattle init --problem path/to/spec.algo --language someLanguage`](getting_started.md#setting-up-the-workspace) + with the first parameter being the file you just downloaded and the second being the programming language you + want to use -4. Download the Problem your course instructors gave you. - -5. Put your code into the `generator` and `solver` subfolders of the problem folder. Each folder needs to contain a - Dockerfile that builds into an image which can be executed to either generate problem instances or solve them. We - have an example [generator](programs.md#generator) and [solver](programs.md#solver) setup. +4. [Write your programs](programs.md#what-it-needs-to-do) +5. [Run `algobattle run` and watch your programs battle against each other](match.md) diff --git a/docs/tutorial/installation.md b/docs/tutorial/installation.md index bf097581..7c52e11d 100644 --- a/docs/tutorial/installation.md +++ b/docs/tutorial/installation.md @@ -1,18 +1,18 @@ # Installing Algobattle -The first thing we'll need to do is install the Algobattle package. There's several different ways to do this. -Which one you choose is entirely up to you and won't change the way the package behaves. For each step we've outlined -what we think is the easiest option and also outlined some alternatives. +The first thing we'll need to do is install the Algobattle framework. There's several ways to do this, which one you +choose is entirely up to you and won't change the way things behaves. For each step we've outlined what we think is the +easiest option and also outlined some alternatives. ## Setting up your environment ### Installing Python -Algobattle is a python package, so the first step is to make sure we have a recent python version available to us. -In particular, we need Python 3.11 or higher. If you already have that installed you can skip to the -[next section](#installing-docker). +Algobattle is a python package, so the first step is to make sure we have Python version 3.11 or higher available to us +in an environment that you can install the Algobattle package and some dependencies into. If you already have that +setup or know how to do it, feel free to skip to [the next section](#installing-docker). /// question | Not sure if you've already got python 3.11? You can check if you've already installed Python and what version it is by running `python --version`. @@ -20,54 +20,58 @@ You can check if you've already installed Python and what version it is by runni /// abstract | Our recommendation There's a few different ways to install and use Python. If you don't care too much about the specifics, we recommend -using Conda as described [below](#inside-a-virtual-environment). +using Conda since it can do everything in one tool. There's several other programs that do similar jobs, and they all +will have the same result. If you're already using one of them, feel free to just stick to using that one. /// -#### Inside a virtual environment -Python doesn't do a great job of managing package dependencies, so people have developed tools to help us with that. -They let you create _virtual environments_ and easily install specific Python versions, packages, and their -dependencies in them. This means you need to install the environment manager first, but will save you some hassle in -the long run. +#### With Conda -We recommend using [Conda](https://anaconda.org/anaconda/conda). You can install it on all major operating systems, and -it will take care of most things for you. On Linux, you can also use [pyenv](https://github.com/pyenv/pyenv). It's a -bit smaller but also requires a bit more care taken manually. +Conda is very easy to use and manages python versions, virtual environments, and packages for you. You can get an +installer for your operating system from the [official Conda website](https://anaconda.org/anaconda/conda). Once you've +got it running you don't need to do anything else for this step since it will install python for you when we make a +virtual environment. -Once you've installed either one you can just create a new environment and have it install python 3.11 for you: +#### Manually -/// tab | conda -```console -conda create -n algobattle python=3.11 -``` -/// +You can also install Python manually from [the Python website](https://wiki.python.org/moin/BeginnersGuide/Download), +or use another package manager. -/// tab | pyenv -```console -pyenv virtualenv 3.11 algobattle -``` -/// +### Virtual environments +Python doesn't do a great job of managing package dependencies, which can cause issues if Algobattle needs a version +of a library (or of Python itself) that some other program you use is incompatible with. To prevent this issue we use +_virtual environments_, which basically behave as though they are separate installations of Python that do not affect +each other. This means we can just make a fresh environment with Python 3.11 and install Algobattle there and never +have to worry about anything breaking. -Just remember to always activate the environment before trying to install or run Algobattle: +First we create a virtual environment like this -/// tab | conda ```console -conda activate algobattle +conda create -n algobattle python=3.11 ``` -/// -/// tab | pyenv +This process may take a second if it needs to download and install python. Once it's done we can now _activate_ the +environment + ```console -pyenv activate algobattle +conda activate algobattle ``` -/// -You won't need to rerun this command before every time you use Algobattle, only once per shell session. +What this does, is make your current shell session use the Python installation from that environment. So if you now run +`python --version` you should see 3.11, not whatever your global installation is (if you even have one). The environment +will stay active until you close the shell or run `conda deactivate`. For everything other than Python commands your +console will keep behaving just like it normally would. -#### Globally +/// tip +Always remember to activate this environment when you want to use Algobattle. You won't need to do it every time +you run a command, but once when you start a new terminal. If you're using an IDE like VSCode you can also configure +it to automatically activate the environment whenever you open a console in a specific project. +/// -If you don't want to deal with yet another program you can also just install python globally on your computer. -[The official python wiki](https://wiki.python.org/moin/BeginnersGuide/Download) has download links and instructions -specific to your operating system. +/// warning | Using the global Python +If you have a global Python installation that is 3.11 or higher you can also skip making a virtual environment. This +generally is not a great idea, but if you really want to you can do it. In that case we recommend installing Algobattle +into user space as explained in the last step. +/// ### Installing Docker @@ -82,8 +86,15 @@ get, we recommend Docker desktop as it provides a nicer user experience. ### Installing Algobattle -Installing Algobattle itself is the easiest part of everything since we can use the package manager we set up earlier. +Installing Algobattle itself is the easiest part of everything since it's available in the +[Python Package Index](https://pypi.org/project/algobattle-base/). All we need to do is make sure the correct +environment is active and run this ```console pip install algobattle-base ``` + +/// warning | Using the global Python +If you really want to install Algobattle into the global environment we recommend running +`pip install --user algobattle-base` instead. +/// diff --git a/docs/tutorial/match.md b/docs/tutorial/match.md index 8817d255..b3bde1d9 100644 --- a/docs/tutorial/match.md +++ b/docs/tutorial/match.md @@ -1,167 +1,129 @@ # Running a match -With a ready to use install of everything we need, we can jump right into actually running some matches and see how that -works. +## Overview -/// tip -You can follow along by downloading the [Algobattle Problem](https://github.com/Benezivas/algobattle-problems) -repository. You can also choose different problems than the ones we'll be discussing here, they all are interchangeable. -/// +Now that we've got everything we need and have even written some working code we can try running an actual match. +A _match_ is basically Algobattle's way of determining whose code generates the hardest instances and solves them the +best. It does this by running everyone's generator against everyone else's solver in what is called a _battle_. -## Selecting a problem +??? Example + Let's say there are three teams Crows, Cats, and Otters. The battles will then look like this -The first step is that we need to tell Algobattle what problem to use. Recall that by _a problem_ we really mean a -certain type of python class. There's two ways of specifying which one Algobattle should use: + | Generating | Solving | + |------------|---------| + | crows | cats | + | crows | otters | + | cats | crows | + | cats | otters | + | otters | crows | + | otters | cats | -1. The path to a file containing it. The path can either be to the file directly, or to the parent folder -as long as the file is called `problem.py`. The file must uniquely identify a problem class, most commonly this is -achieved by it only containing a single one, but more complex use cases are possible too. +!!! tip + If there's only one team Algobattle will run a single battle with that team performing both roles. You can use this + to easily try out how well your code performs. -2. The name of a problem. For this to work the problem needs to be part of an installed problem package. +Algobattle also lets course instructors customize what each battle looks like. This is usually done much more rarely +than changing up the problem, so you won't have to learn much more stuff! Throughout this page we will be using the +Iterated battle type since it's the default and explains things the best. The idea behind Iterated battles is that we +want to figure out what the biggest size is where the solving team can still correctly solve the generating team's +instances. We do this by first having the generator create an instance up to some particular size. Then the solving team +must solve this instance. If it manages to do so, we repeat this cycle (called a _Fight_) with a bigger maximum instance +size. At some point the instances will be so big that the solver can't keep up any more and produces a wrong solution. +The last size where the solving team still was correct becomes that team's score. -/// tab | Path -
+!!! info "More details" + The process described above is the best way to explain this battle type, but it's not actually precisely how it + works. You can find the actual process description in our [battle types](/advanced/battle_types.md) page. -```console -algobattle .\algobattle_problems\pairsum -``` - -
-/// +## Let's get started -/// tab | Name -
+To run a match we just execute ```console -algobattle Pairsum +algobattle run ``` -
-/// +This will display a lot of info to the command line. We will now go through it all step by step and explain what +Algobattle is doing and what the interface is trying to tell us. -/// info -Depending on your exact setup this command may throw an error right now, we'll see why and what to do to fix it in a -bit. -/// +## Building programs -## Building program images +The First part of a match is building every team's programs. Depending on how complicated they are this may take a +little while. During this step Algobattle gets all the programs ready for execution, compiles and installs them, etc. -Algobattle needs to not only know what problem to use, but also what teams are participating in the match and where it -can find their programs. For now, we'll use the default of a single team that fights against itself. This setup is often -used during development when teams want to test their own code. +??? question "You can't just skip over what's actually happening!" + Yes I can :wink:. The actual details of this are somewhat complicated if you're not familiar with Docker (and if + you are, you'll have already figured our what's going on) so we recommend skipping over this for now. We recommend + skipping over the details here for now and if you still want to learn more later you can check out the + [advanced guide on Docker](/advanced/docker.md#building-images). -When the problem is specified via a path, Algobattle defaults to searching for the programs at the `/generator` and -`/solver` subfolders of the directory the problem is in. If you provide the name of a program, it will look for those -folders in the current instead. +During this the interface will look something like this -/// note -The problems in the Algobattle Problems repository all have dummy programs at the required paths. If you are using -different problems you will need to write your own programs before you can run a match. +```{.sh .no-copy} +{!> interface_build.txt !} +``` -Since these dummy programs are located in the package folders, you will need to specify the program with a path to it to -use them. -/// +!!! bug "This looks much better with colours" + Don't worry if you find this hard to read here, it should be a lot more readable in your terminal with proper + alignment and colour rendering. -These folders should contain Dockerfiles that build into the team's programs. The first thing that happens during a -match is that Algobattle builds the containers. During this the terminal will tell you whose programs are being build, -how long this is taking, and what the timeout is set to. +This should be fairly straightforward, the top progress bar tracks how many programs need to be built in total and +below we have a full listing of every participating team. There are two programs here since there is only one team with +a generator and a solver. -## Match execution +## The match itself With the programs built, the actual match can start. While this is happening a lot of different things will be displayed, so let's go through each of them on its own: -### Battle overview +### Match overview -```console hl_lines="2-11" -{!> match_cli.txt!} +```console hl_lines="3-8" +{!> interface_match.txt!} ``` -Right at the top you will see an overview over all battles in this match. Normally this includes every combination of -one team generating and another team solving, but there are some exceptions. The first is that if there is a single -participating team (as is the default), then it will instead be paired up against itself. The other is that teams are -excluded if their Docker containers don't successfully build. +Right at the top you will see an overview over the whole match. This table lists every battle in the match, its +participants, and what the result of that match was. For the Iterated battle type the result just is the highest size +the solving team was able to solve. -The First two rows contain the names of the teams participating in that match, and the third is the score of that -battle. Note that this is not the same as the points each team gets awarded at the end. Rather, it just represents a -battle specific measure of how well the solving team performed in it. The final points calculation is explained -[here](#points-calculation). +Everything below this is specific to each battle. It starts off by just showing you who the generating and solving team +is. -### Battle data +### Current fight -```console hl_lines="14-15" -{!> match_cli.txt!} +```console hl_lines="12-16" +{!> interface_match.txt!} ``` -Each battle also has its own specific data it will display. In our example, we are using the Iterated battle type which -runs fights at increasing instance sizes until the solver can no longer solve the generated instances within the time -limit. This is repeated a few times to be fair to teams that implemented programs with random elements. The `reached` -field indicates what the maximum size reached in each iteration was, here the first repetition got to 12 and the second -has currently not executed any fights. The `cap` field is a bit more intricate and explained in detail in the -[Battle types](battle_types.md) section. +On the left we have info on the current fight. What maximum size it is using, and basic data on the programs. Here we +can see that the generator has already run successfully using only half a second of the 20 it has, and the solver is +still running at the moment. -### Current fight - -```console hl_lines="17-19" -{!> match_cli.txt!} -``` +### Battle data -The fight that is currently being executed displays what size it is being fought at and timing data about each program -execution. +On the right we see some data specific to the battle type. If you want to learn what the Iterated type displays here, +check out its documentation in the [battle types page](/advanced/battle_types.md#iterated). ### Most recent fights -```console hl_lines="21-29" -{!> match_cli.txt!} +```console hl_lines="17-26" +{!> interface_match.txt!} ``` -Lastly, the three most recent fights have their score displayed. - -## Points calculation - -Once all battles have finished running each team will get some number of points based on how well it performed. By -default, each team can get up to 100 points in a match. In the case of a three team matchup like we have here this means -that there are 50 points divided out between each pairing here. How they are divided is determined is based on how well -a team was able to solve another team's instances compared to how well that other team was able to solve the ones it -generated. For example, if the battle scores look like this: +At the bottom we can see some more info on the last five fights. The score of a fight indicates how well the solver did, +with the Pairsum problem it can really only pass or fail, but some other problems judge the solution by e.g. how well it +approximates an optimal solution. The detail column will display the runtimes of the programs if everything went well, +or a short error message if some error occurred. Here we can see that all but the second to last fight happened without +issue, but in that one the solver didn't actually output a solution, and thus failed. -| Generator | Solver | Result | -|-----------|---------|--------| -| dogs | cats | 24 | -| dogs | otters | 50 | -| cats | dogs | 12 | -| cats | otters | 700 | -| otters | dogs | 50 | -| otters | cats | 0 | - -Then there are three pairings that are considered: - -1. Dogs vs Cats. Here the battle scores are 12 to 24, team cats was able to solve the presented instances twice as well -as team dogs. So cats receives 33.3 points and dogs 16.6. - -2. Cats vs Otters. This matchup is much more decisive at 700 to 0, obviously the otters will get all 50 points and cats -none. Note that the fact that the total score here was much higher than the ones in the previous matchup is irrelevant, -battle scores are only compared between two particular teams, not over the whole match. - -3. Otters vs Dogs. This matchup again is very simple as both teams performed equally well, so both will receive 25 -points. - -In total, dogs win this match with 41.6 points, cats are second with 33.3, and the otters are third with 25. - -## Save match results - -Algobattle keeps a detailed log of everything that happens during a match, including many things that are not displayed -to the terminal during execution. This is especially useful during development since it includes error messages of -programs that failed to run. All you need to do to enable this is passing a path to a folder where you want them to be -saved when running the match - -
- -```console -algobattle .\algobattle_problems\pairsum --result_output=.\algobattle_logs -``` +## Finishing the match -
+To get the full results you need to wait until the match is done running everything it needs to. But this can take quite +a while, if you want you can safely cancel it early by pressing ++ctrl+c++. +Algobattle will handle stopping any running programs and log the (partially) completed match to the file path it prints. +This file will also contain more detailed error messages that didn't fit on the command line interface for every error +that happened during the match. +Finally, the leaderboard is displayed. Points are allocated by comparing how well each team did against each other team. diff --git a/docs/tutorial/overview.md b/docs/tutorial/overview.md deleted file mode 100644 index 3e526b6d..00000000 --- a/docs/tutorial/overview.md +++ /dev/null @@ -1,119 +0,0 @@ -# Overview - -First, we'll take a look at the broad structure of everything and the terms we use. The following pages then build on -each other to delve deeper into each concept. At the end you'll be ready to use the package to either participate in -a lab course or organize one. Not everything is covered in it though, optional topics that not everyone needs to -understand are explained in the [advanced guide](/advanced/index.md). - -/// tip -If you prefer a more hands-on approach you can skip directly to the [next page](installation.md) and use this -as a reference page to come back to. -/// - -## Problems - -The Algobattle lab course is about solving algorithmic problems. So what is that exactly? From a theoretical computer -science perspective this doesn't have a clear answer, there are decision problems, function problems, optimization -problems, etc. What all of them share is that a problem is a description of what its instances look like, and what -correct solutions to them are. - -Let's look at an example problem, -[Pairsum](https://github.com/Benezivas/algobattle-problems/tree/main/problems/pairsum): -The abstract definition of it is that given a list of natural numbers, the task is to find two pairs of numbers in it -with the same sum. For example, in the list `1, 2, 3, 4, 5` we find `2 + 5 = 3 + 4`. - -Of course, we don't want to just accept any four numbers that sum to the same, they need to be numbers actually present -in the list. The easiest way to ensure this is making the solution actually contain four indices into the instance list -instead of the numbers found there. Then our example solution becomes `l[1] + l[4] = l[2] + l[3]`. - -To make it possible for programs to easily interact with this abstract definition, we need to specify what exactly a -problem instance and solution looks like. Since Pairsum only uses simple numerical data, it uses regular json. The -example instance then looks like this: - -```json -{!> pairsum_instance.json!} -``` - -And its solution is: - -```json -{!> pairsum_solution.json!} -``` - -/// note -Most other problems also use json because it's such an easy to use and widely supported format. But some problems need -to encode more exotic data and thus use different formats. -/// - -We can already see an important property of problem instances: their _size_. You can easily find a correct pairing -in a list of 5 elements by hand, but that becomes much more difficult if there's 5000 numbers instead. Different -problems define the size of their instances differently, but it is usually in line with the natural notion of it. For -example, common graph problems use the number of vertices, numerical problems the size or amount of numbers, etc. -Generally, larger instances are significantly harder to solve and as such we never compare instances of different sizes -directly. Teams compete against each other based on how big the biggest size they can solve is, how quickly they can -solve instances of the same size, etc. - -Another thing about Pairsum is that its solutions are scored purely on a pass/fail basis. Either you've found numbers -that add up correctly or you didn't. This is different for other problems such as finding the biggest independent set -in a graph. In that problem we can compare two solutions and say that one is, say, 20% better than the other since it -contains 20% more vertices. - -## Programs - -Each team of students now is tasked with writing code to actually solve such problems. But not only that, they also need -to generate instances for the other teams to solve. This means that each team needs to not only think about efficient -ways to take arbitrary instances and find solutions for them, they need to also figure out what it is about instances -that makes them particularly challenging. - -Each team writes two _programs_ for this, a _solver_ and a _generator_. The generator will take a size as input and -produce a problem instance. The solver takes an instance and creates a solution for it. In tournament matches, teams -will always generate instances that other teams' solvers then attempt to solve, but teams can also run their own solvers -against their generator in order to practice and debug. - -We use docker to provide each team with a fair and controlled environment to execute their code in. A program really -just is a docker image that we then run as a new container every time a new instance is to be generated or solved. This -lets students choose their approach and tools incredibly freely, there is no constraint to specific programming -languages, system setups, libraries used, etc. On the other side, we maintain total control over the runtime -environment. Because all student code is executed inside a container there is no danger of it manipulating the host -system maliciously and its resource use and runtime can be easily limited. - - -/// abstract | Docker basics -Docker is a tool that lets you share code in controlled environments. Say you have some rust code that also requires -C++ external libraries. If I want to run that, I'd need to first install both the rust and a C++ compiler and set up -my environment variables like `$Path` properly. If I don't know exactly what your code needs and how it works that'll be -really annoying and finicky. -By using Docker you can instead just create an _image_. This is basically a small virtual machine that has whatever you -want installed and configured. I then take that and create a _container_ from it, which runs the code exactly like you -specified. - -You can find much more detailed info on how Docker works on [the official site](https://docs.docker.com/get-started/). -If you just want to know how to use it to write your team's programs, [this part](programs.md) of the tutorial will tell -you all the basics. -/// - -## Matches - -Now we can talk about the most exciting part of Algobattle, the actual matches themselves! They're what happens when you -run the project code and will score how well each team's programs are performing. A match pairs up every team against -every other team and then runs a _battle_ with that specific matchup of teams. Once all the battles have run, it -compares all the scores achieved in them to calculate overall scores for every team. - -So each battle is between two specific teams. In particular, one team is tasked with generating problem instances, and -the other attempts to solve them. The battle judges how well the teams did against each other calculates a score based -on that. How exactly this happens depends on the particular battle type chosen for the match. The default battle type -sets a static time limit for the generator and solver and then increases the instance size until the solver can no -longer compute a valid solution for the generated instance within that time limit. If, for example, team `dogs` is -very good at generating hard instances and team `cats` can only solve them up til size 50, then the battle would -roughly start with `dogs` creating an instance of size 5, which `cats` can easily solve, this repeats at size 10, then -20, etc., until a size bigger than 50 is reached and the solver of `cats` can no longer provide a correct solution. The -score would then be 50. - -Each of these cycles of one team's generator creating an instance of a specific size and the other team's solver trying -to calculate a valid solution is called a _fight_. Each fight is run with some set of parameters determined by the -battle and uses these to control how the programs are run. - -The result of a fight is summarized by its _score_. This is a real number between 0 and 1 (inclusive) that indicates how -well the solver did. A score of 0 means it did not provide a valid solution, and 1 that it solved it perfectly. -Fractional scores can happen when for instance the problem is to find the biggest vertex cover and the solver finds one -that is 80% of the size of the optimal one. In this case, the fight would have a score of 0.8. diff --git a/docs/tutorial/programs.md b/docs/tutorial/programs.md index 5881e23a..f0d8feeb 100644 --- a/docs/tutorial/programs.md +++ b/docs/tutorial/programs.md @@ -1,445 +1,295 @@ # Writing programs -The main activity in the Algobattle lab course is of course writing the actual code to solve problems. This page will -walk you through the entire process of how that is done and explain what you need to know in order to write your own -programs. +## Problems -## The Docker environment +Now that we've got our project setup we can take a look at what we actually need to do: solve some problem. In this page +we'll work with the [Pairsum problem](https://github.com/Benezivas/algobattle-problems/tree/main/problems/pairsum). It's +a nice and easy starting point to get familiar with things, but you can also jump right into things with the problem +your course instructors gave you. -If you haven't used Docker before, getting your head around what it's doing and what you need to do can be a bit -confusing. Luckily we do not need to understand most of its functionality and the parts that we do need are pretty -straightforward. You can think of Docker as a virtual machine management tool, it lets you create _images_ that are -basically savefiles of an entire computer including the OS and everything else installed on it. We can then run -_containers_ from these images, independent virtual machines that start off from that savefile and then run some -code. So when we want to write a generator we need to create an image that has all our code in it and some -way to run that code. When an image of it is run it then executes that code and generates an instance. +### What is a problem? -Images are run essentially as virtual machines that are entirely separate from the host machines' OS. This means that -you can't directly interact with the program itself to debug it or look at its output. It also means that you need to -specify everything that you need to be there, most importantly the code that you want to run and the compiler or -interpreter needed for it. +The Algobattle lab course is about solving algorithmic problems. So what is that exactly? From a theoretical computer +science perspective this doesn't have a clear answer, there are decision problems, function problems, optimization +problems, etc. What all of them share is that a problem is a description of what its instances look like, and what +correct solutions to them are. -### Dockerfiles +This idea is what Algobattle works with, a problem really is just some specification of what _instances_ look like and +what _solutions_ of these are. For Pairsum this is very straightforward, each instance is a list of natural numbers and +a solution is two pairs of them that have the same sum. For example, in the list `1, 2, 3, 4, 5` we find `2 + 5 = 3 + 4`. +Unfortunately computers aren't quite clever enough to just take such an abstract definition and work with it directly, +so each Algobattle problem also defines how instances and solutions should be encoded/decoded. Pairsum uses json for this, +so the example above looks like this: -When you tell Docker to create an image using the content in the `/generator` folder, the first thing it does is -look for a file at `/generator/Dockerfile`. It needs to be in a special file format that specifies exactly what the -image should look like. You can think of Docker as creating a new virtual machine with absolutely nothing installed -on it (not even an OS) and then running each command in order. Once everything has been executed it then saves the state -of the file system of this virtual machine. The image then just refers to that save file and each image of it will -be executed in a virtual machine that looks exactly like it. +!!! example "Example instance" -The full specification of what Dockerfiles can contain is [here](https://docs.docker.com/engine/reference/builder/), but -most of it is not relevant for us. This example contains all commands you will probably need: + ```json + {!> pairsum_instance.json!} + ``` -```Dockerfile -FROM python +!!! example "Example solution" -RUN pip install tqdm -COPY main.py / + ```json + {!> pairsum_solution.json!} + ``` -ENTRYPOINT python main.py -``` - -/// abstract | Dockerfile summary -If you don't care about the details and just want to write your code, here's the super short summary for you: - -- Start your Dockerfile with a `#!Dockerfile FROM` statement that uses the name of language your code is in. For -example, `#!Dockerfile FROM python:3.11` or `#!Dockerfile FROM rust`. This will give you a Linux environment with that -language's compiler/interpreter installed. You can optionally specify a version after a colon. - -- If you need access to files, copy them into the image with `#!Dockerfile COPY source/path target/path`. - -- You can run shell commands during the build step with `#!Dockerfile RUN some shell command`. - -- Specify the shell command that actually executes your code with `#!Dockerfile ENTRYPOINT run my code`. -/// - -/// tip | Speedup build times -Docker image builds are cached, you can significantly speed up your development process by ordering the commands -correctly. Docker executes each line one by one and only newly executes lines following the first line that depends on -something that has changed. Further, `#!Dockerfile RUN` commands are assumed to be deterministic and only dependent on -the state of the file system immediately before their execution. - -In particular this means that you generally want to order `#!Dockerfile RUN` commands as early as possible, and -`#!Dockerfile COPY` the files you change the most last. -/// - -#### The `#!Dockerfile FROM` statement - -The first line of every Dockerfile has to be a `#!Dockerfile FROM` statement, the most basic example is -`#!Dockerfile FROM scratch`. This line tells Docker what to base your image off of, `#!Dockerfile FROM scratch` -means that it starts with a completely empty file system. If we do that we need to first install an operating system, -configure it enough to be usable, and then install whatever we actually want to run. We can make our Dockerfiles much -simpler by using one of the already existing images in the [_Docker Hub_](https://hub.docker.com/) -in our `#!Dockerfile FROM` statement instead. Instead of starting with an empty file system we then start with the file -system of that image. - -All major operating systems have images containing a fresh installation of them on the Docker Hub. For example, -[here](https://hub.docker.com/_/alpine) is the official Alpine image, [here](https://hub.docker.com/_/ubuntu) is Ubuntu, -and [here](https://hub.docker.com/_/debian) is Debian. If you want your code to run in a clean environment with nothing -else you can use any of these as your base. - -/// warning -In principle Docker can also run Windows OS's inside the containers, but this requires special setup on the host -machine. In particular, every image needs to then be a Windows image, there is no way to control both Linux and Windows -containers at the same time. We recommend course administrators configure Docker to run Linux containers (this is the -default) and inform students that they are required to use Linux in their images. - -Talk to your course administrators if you are a student and unsure about what OS to use. -/// - -Since you want the container to execute some code you will most likely then need to install a compiler or runtime for -whatever language you're using. We can easily skip this intermediary step and instead base our image off of one that -already includes this. Most languages have officially published images that contain some Linux distro and an -installation of everything that compiler/interpreter needs to work. For example, [here](https://hub.docker.com/_/python) -is Python's and [here](https://hub.docker.com/_/rust) Rust's. - -Images on the Docker Hub can also be versioned using tags. For example, the official Python image has dozens of slightly -different versions that come with different OS's, Python versions, etc. If you want to use a specific tag you need to -list it in the `#!Dockerfile FROM` statement after a colon. For example, if your code needs Python 3.10 you can write -`#!Dockerfile FROM python:3.10`. - -/// tip -Different languages use different schemes for tagging their images. Always check the official page on the -[Docker Hub](https://hub.docker.com/) to make sure you're getting the right version of everything. -/// - -#### `#!Dockerfile COPY`ing files - -Now that we have a container that includes our language's runtime we also need to include our code and all other files -we may need. The `#!Dockerfile COPY` command does exactly this. For it, we just list the path to the file on the host -file system, and the path it should be at in the image. Our example has the generator code in a single file next to the -Dockerfile, so we can place it into the root directory of the image with `#!Dockerfile COPY main.py /`. +Where the solution is the list of the indices of the numbers that we found, 2 is at index 1, 5 at 4, etc. -///attention -Copying files that are not inside the folder containing the Dockerfile (or a subfolder of it) requires additional steps -and may not work when sharing the code with course instructors. We recommend you place everything you need in that -directory. -/// +### What do we need to do? -If you want to split up your code over multiple files or include other files such as configs, you can add any number of -additional `#!Dockerfile COPY` statements. You can use glob patterns to copy multiple files once, for example -`#!Dockerfile COPY . usr/src` will copy the entire directory on the host machine to `usr/src` in the image. +Previously we've said that the code we're going to write will solve problems, but that is only half of the truth. What +we actually need to do is write two different programs for each problem, one that _generates_ instances and one that +_solves_ them. In a stroke of creativity typical for computer science we'll call these the _generator_ and the _solver_ +and use the correspondingly named subfolders for them. -#### `#!Dockerfile RUN`ning commands +??? info "Project config" + This is why the `teams` table in the project config has that structure. It tells Algobattle which teams' programs + can be found where. -You can use `#!Dockerfile RUN some shell command` to execute `#!shell some shell command` in a shell during the image -build step. This command will have access to everything that was copied into the image beforehand and anything that -previously ran commands created. Most often, this is used to install dependencies of your program. +## Generator -This statement has two forms, the first `#!Dockerfile RUN some shell command`, and the other -`#!Dockerfile RUN ["some", "shell", "command"]`. For our purposes they do largely the same thing, but their differences -are explained [here](https://docs.docker.com/engine/reference/builder/#run) +### Setup -#### The program `#!Dockerfile ENTRYPOINT` +The first step in writing a program is deciding what language you want to use. Algobattle lets you choose whatever +language you want, whether that be Python or rust, or even more esoteric choices like Brainfuck or PROLOG. But it's a +lot easier to use one of the more common languages since it comes with some project templates for them. The list of +these is: -Lastly, the container that runs from your image needs to know what it should actually do. You can specify this with the -`#!Dockerfile ENTRYPOINT` statement. Its arguments form some shell command that is not executed during the build step, -but when the container starts. +- Python +- Rust +- C +- C++ +- C# +- JavaScript +- Typescript +- Java +- Go -Similar to run this command also has the same two forms, and you can choose whichever you prefer. They are explained -in detail [here](https://docs.docker.com/engine/reference/builder/#entrypoint). +!!! failure "Can't find your favourite language?" + If the language you want to use isn't on here you can still use it, but you have to set some things up yourself. + It's probably easier to get started with one of these first and then once you're familiar with everything switch to + what you want to stick with. -### An example image +??? tip "Help us make Algobattle better" + Some languages either have no templates or some very bare-bones ones. This is mainly just because we aren't familiar + enough with every language to provide better support. If you want to help us out make Algobattle even more awesome + you can open an issue or submit a pull request on [our GitHub](https://github.com/Benezivas/algobattle) with a + better template for your language. -The best way to fully understand how all this works is to run a quick example. For this we will be writing a simple -Python program that displays a short progress bar in the command line. It uses the Dockerfile we saw above - -```Dockerfile -FROM python - -RUN pip install tqdm -COPY main.py / - -ENTRYPOINT python main.py -``` - -And this Python file: - -```python title="main.py" -from time import sleep -from tqdm import tqdm - -for i in tqdm(range(20)): - sleep(.1) -``` - -We see that the [tqdm](https://pypi.org/project/tqdm/) library we use to display a progress bar is installed in the -image and our code is copied over. When the container is run, it will just execute the python script. - -To test this setup we first build the image with Docker - -
+We can then rerun the project initialization step and also tell it what language we want to use, Python in this example. +Since the project is already initialized we can just omit the `--problem` option to reuse the current setup. ```console -docker build ./generator/ -t test_image +algobattle init --generator python ``` -
- -/// note -the `-t` parameter lets us _tag_ the image that is created to make it easier to use later on. A full specification of -all parameters can be found in the [official docs](https://docs.docker.com/engine/reference/commandline/build/). -/// - -To then create and run a container from it, we run - -
- -```console -docker run -it -rm test_image +!!! info "Overriding data" + Whenever you tell Algobattle to do something that would override already existing files it will ask you if you want + to continue. Make sure that you only confirm if you don't need these files any more. In this example the data is just + the initially auto-generated file we made when we set up the project folder, so we can safely replace it with the + python template. + +Our project folder should now look something like this + +``` { .sh .no-copy } +. +└─ Pairsum + ├─ generator/ + │ ├─ .gitignore + │ ├─ Dockerfile + │ ├─ generator.py + │ └─ pyproject.toml + ├─ results/ + ├─ solver/ + │ └─ Dockerfile + ├─ .gitignore + └─ algobattle.toml ``` -
- -/// note -The `-ti` parameters let you see the output of the process running inside the container, and `-rm` cleans up the -container after it has finished running. The (rather lengthy and complicated) description of all parameters is again -found in the [official docs](https://docs.docker.com/engine/reference/run/). -/// +The important file here is `generator.py`, we need to put the code that we want to run as our generator in there. -We recommend running through this example yourself and trying out various changes to the Dockerfile to see how it -affects what happens when you run the container. Don't forget to always build the image again when you change something! +??? question "What's `pyproject.toml`?" + This is the file that Python uses to specify package metadata such as dependencies, project names, etc. It's + already filled out with the data we need so we can just leave it as is for now. -## Interacting with the match +??? question "What's `Dockerfile`?" + This is the file that specified what Docker is supposed to do with our code. What exactly Docker does and what the + Dockerfile says is rather complicated and not super important for us right now. It's explained in detail in the + [docker advanced guide](/advanced/docker.md). -Now that we know how write programs and get them to run, we need to figure out how to make them interact with the -Algobattle matches correctly. The broad overview is that when Algobattle runs a program it creates two folders in its -root directory, `input` and `output`. As the names suggest, the first contains files that are the input of the program -and the second is where Algobattle expects its output to be. +### What it needs to do -/// danger -The input folder is read-only to prevent programs from mistakenly placing their outputs in the wrong folder. Attempting -to write to it will cause errors. -/// +When we look in there it's rather empty right now: -/// danger -All output files must be placed in the `output` directory during container execution. Any files created there during the -image build will be ignored by the match. -/// +```py title="generator.py" +"""Main module, will be run as the generator.""" +import json +from pathlib import Path -### Metadata -Both types of programs will find a file `info.json` in the input directory. It contains some basic info about how the -program is run and the resources it has available. In particular, its keys are: +max_size = int(Path("/input/max_size.txt").read_text()) # (1)! -`max_size` -: The instance size of this fight. If the program is a generator, its output must be - smaller than it and if it is a solver the input instance is guaranteed to be smaller than it. +instance = ... +solution = ... -`timeout` -: The timeout in seconds, or none if there is no timeout. This is the maximum time the program is allowed to run - before it will be stopped. - -`space` - -: The amount of memory space in MB that this program has available, or none if there is no limit. If it attempts to - use too much memory, the memory is swapped with disk space leading to very big performance decreases. - -`cpus` - -: The number of physical cpu cores the program is allowed to use. - -/// example | Example (with more human-readable formatting) -```json title="input/info.json" -{ - "max_size": 17, - "timeout": 20, - "space": none, - "cpus": 2 -} +Path("/output/instance.json").write_text(json.dumps(instance)) # (2)! +Path("/output/solution.json").write_text(json.dumps(solution)) ``` -/// - -/// info | Advanced -The input may also contain a file or folder called `battle_data`, this is an advanced feature of some battle types and -explained [here](../advanced/battle#battle-data). -/// - -### Problem data - -What exactly problem data looks like is highly dependent on the problem being used. Many of them use a single json -file, but other file types and even entire folders are possible. Throughout this section, when we talk about an instance -or solution we mean either a file or a folder depending on what particular problem is being used. - -Generally, problems that use single files also use file extensions to indicate the correct type of encoding being used. -In this case the names we are using here refer to only the stems of the actual file names found on disk and need to be -suffixed with the correct extension. -For example, when we say that you should expect a problem instance named `instance`, the actual file system of the -container will most likely contain a file called `instance.json`, but it might also be `instance.png` or a folder -`instance/` containing several files. +1. This opens the input file and parses it into an integer. -#### Generators +2. This writes the generated instance/solution as correctly formatted json to the output file. -Generators will also find a file called `max_size.txt` in their input directory. This simply contains the maximum -allowed size of the instance they are tasked with generating. +The first thing that stands out is that we read from and write to some rather weird files. This is very intentional! +When Algobattle is run your program won't see the actual filesystem of your computer but a more or less empty Linux +install. It will also see two special folders there: `/input` and `/output`. As their names suggest these are responsible +for holding the input to the program and where Algobattle looks for its output. -/// example -```text title="input/max_size.txt" -17 -``` -/// +So if a generator's job is to just create some difficult instance, why is it getting any input? This is because in +principle a generator could make its job very easy by not actually making _hard_ instances but just making _big_ ones. +Finding pairs of numbers with the same sum is going to be much harder in a 10000 number long list than one with only 10 +after all. To make things more comparable and not just about who can write the most data to a file Algobattle forces +us to stick to some given upper limit of size of instance we make. -Generators are tasked with creating a problem instance. It needs to be placed in the `output` folder and called -`instance`. +!!! info + Usually your generator will be called with various different instance sizes, don't assume that it will always be the + same. But on the other hand, you can always output an instance that is smaller than the asked for maximum size. -/// example -```json title="output/instance.json" -{!> pairsum_instance.json!} -``` -/// +??? question "What exactly is an instance's size?" + The exact definition of an instance's size depends on the particular problem. Most of the time it is what you'd + intuitively understand it to mean though. In Pairsum's case it is the length of the list, practically all graph + problems use the number of vertices, etc. If you're unsure check the problem description or ask your course + instructors. -Additionally, many problems require the generator to also output a solution. This can be used to ensure that the -instance is actually solvable or to compare the quality of the solver's solution against the best one possible. -If it is needed Algobattle expects it to be called `solution` in the `output` folder. +The code then writes things to the output directory, but it doesn't just write the instance, it also writes a solution. +It might seem weird at first, but many problems do require the generator to not only come up with an instance, but also +solve it. This is to make sure that the instance does indeed have a solution. Otherwise, we could just make some list +of numbers were no two pairs have the same sum and then always win no matter how good the other teams' solvers are! -/// example -```json title="output/solution.json" -{!> pairsum_instance.json!} -``` -/// -#### Solvers +### Writing the code -Solvers will instead find an encoded instance named `instance` in their input. This is the instance that the other team -generated, and the solver is required to solve. +Now comes the hardest part, writing the code that actually does all that! What exactly that looks like will depend on +the particular problem you're working with, the language you chose, your workflow, etc. The great part is that Algobattle +lets you have a lot of freedom here, you are completely free to write the code how you want to. To keep going with this +tutorial we've provided an example generator here, but the particularities of it aren't super important for you to +understand. -/// example -```json title="input/instance.json" -{!> pairsum_instance.json!} -``` -/// +!!! example "Example generator" + An easy way to make a generator for Pairsum is to just output a bunch of random numbers: -Their solution needs to be named `solution` and placed in the `output` directory. + ```python title="generator.py" + {!> pairsum_generator/start.py !} + ``` -/// example -```json title="output/solution.json" -{!> pairsum_solution2.json!} -``` -/// + 1. Generate `max_size` many random numbers in the 64-bit unsigned integer range. -## A complete example + 2. Pairsum expects a json object with a single key, `numbers` that contains the list of numbers. -To get more familiar with everything we can now write a simple generator and solver and try it out. We will again be -using the Pairsum problem for this. -### Generator + But if we do that we'd then have to also actually solve this instance and if we're particularly unlucky that might not + even be possible! So it's better if we don't make the entire list random and insert a handcrafted solution into the list: -First, we'll tackle the generator. A simple (and surprisingly effective) way to generate fairly hard instance is to just -generate them randomly: + ```python title="generator.py" hl_lines="10 15-25" + {!> pairsum_generator/main.py !} + ``` -```python -{!> pairsum_generator/start.py !} -``` + 1. We now use four fewer random numbers -1. This opens the input file and parses it into an integer. + 2. Create four numbers such that a + b = c + d -2. This generates as many random numbers as are asked for. Note that they are in the range of a (signed) 64 bit int. - Many problems use numbers in this range to ensure easy cross language compatibility. + 3. Insert them into the list at random places -3. This writes the generated instance as a correctly formatted json file at the expected location. +### Trying it out -Pairsum also requires the generator to output a certificate solution. The big problem with our approach is that we can't -do that very easily, in fact we don't even know if there is a correct solution at all! We can work around this by not -generating the instances completely randomly but instead inserting a known solution into an otherwise random list. +Now that we have an actual program we're ready to test it out by running -```python title="generator/main.py" hl_lines="9-15 22-25" -{!> pairsum_generator/main.py !} +```console +algobattle test ``` -1. This opens the input file and parses it into an integer. - -2. This generates as many random numbers as are asked for, minus the four solution numbers we will generate separately. - Note that they are in the range of a (signed) 64 bit int. Many problems use numbers in this range to ensure easy - cross language compatibility. - -3. This writes the generated instance as a correctly formatted json file at the expected location. - -4. Here we now create the known solution. The first three numbers are randomly chosen, and the last is set to ensure a - valid solution. Then they are inserted into the instance at random positions. +This tests both the generator and the solver, so it's expected that it shouts at us right now about the solver not +working since we haven't written that yet. If your generator is written correctly the build and run tests for it should +complete without issue though. If something isn't working quite right you will find the particular error message in the +json document it links to. -5. Finally, we output the certificate solution. +## Solver -Now the only thing left is to create a simple Dockerfile for our program. +With a working generator all that's missing is a solver. During a match this program will get the instances other teams' +generators have created and be asked to solve them. In this example I will use rust for this, but you can again choose +any language you like. First we run the initialization command again -```Dockerfile title="generator/Dockerfile" -{!> pairsum_generator/Dockerfile !} +```console +algobattle init --solver rust ``` -And to place both of these files into the `generator` directory of the Pairsum problem. - -### Solver - -Now we need to figure out how to actually solve instances that are presented to us. Since this is much more complicated -than just randomly generating some numbers we'll want to use a more performant language than Python for this. In this -example I will use rust, but you can choose any language you like. - -The simplest approach is to just iterate over all possible combinations of four numbers from the list and check which -one is a valid solution. - -```rust title="solver/main.rs" -{!> pairsum_solver/main.rs !} +The project then looks like this + +``` { .sh .no-copy } +. +└─ Pairsum + ├─ generator/ + │ ├─ .gitignore + │ ├─ Dockerfile + │ ├─ generator.py + │ └─ pyproject.toml + ├─ results/ + ├─ solver/ + │ ├─ src + │ │ └─ main.rs + │ ├─ .gitignore + │ ├─ Cargo.toml + │ └─ Dockerfile + ├─ .gitignore + └─ algobattle.toml ``` -1. This tells rust how to destructure the json input. +We can again see a similar structure to the Python template, but this time it's using a slightly different layout. -2. Here we just open the input file and parse it. +??? question "What's `Cargo.toml`?" + This is what rust's tool Cargo uses for project specification. We can again ignore the contents of this for now. -3. Now we can iterate over all combinations of four numbers from the instance list. +### Writing the code -4. If the pairs of it sum to the same number we output them as a valid solution. +The solver takes an instance and should produce a solution for it. Similar to the generator these will be in the +`/input` and `/output` directory, but this time called `/input/instance.json` and `/output/solution.json` as you'd +expect. Since we're already familiar with this I/O structure we can get right into writing the actual program. -/// question | Not familiar with Rust? -If you're not familiar with Rust this program probably looks pretty intimidating. You don't need to understand how -exactly this code works, the important bit just is that it iterates over all possible solutions until it finds a correct -one. -/// +This will again widely vary based on how you choose to do things, We've got our rust example solver here: -Since Rust is a compiled language the setup needed to run it is a bit more involved than with Python. First, we need a -`Cargo.toml` file that specifies what our project looks like. The easiest way to do that is running +!!! example "Example solver" + This solver just iterates over all possible combinations of four numbers in the input list and checks if they form + a valid pair. It's horribly inefficient but will do for now :grin: -```console -cargo init -``` -and adding the dependencies we used with + ```rust title="main.rs" + {!> pairsum_solver/main.rs !} + ``` -```console -cargo add serde serde_json -F serde/derive itertools -``` + 1. This tells rust how to destructure the json input -This results in a file like this: + 2. And this how to serialize the output -```toml title="solver/Cargo.toml" -{!> pairsum_solver/Cargo.toml !} -``` + 3. Iterate over all possible combinations of four indices -Finally, the source code needs to be compiled during the build step so that the container can easily run it during -container execution without losing any additional time. + 4. If the pairs have the same number we output them as the solutions -```Dockerfile title="solver/Dockerfile" -{!> pairsum_solver/Dockerfile !} -``` + !!! note + This program uses itertools so we have to run `cargo add itertools` inside the solver directory to add it to + our dependencies. -We put these three files (`main.rs`, `Cargo.toml`, and `Dockerfile`) into the `solver` subdirectory of the Pairsum -problem folder. + ??? question "Not familiar with Rust?" + If you're not familiar with Rust this program probably looks pretty intimidating, but don't worry you won't need to + understand the details of this program. -### Trying it all out -Now we can finally try out our code in a match! With the program files placed at the right locations Algobattle will -find them automatically, all we need to do is point it to the folder containing the Pairsum problem. +### Trying it out -
+Now we can try our programs out again ```console -algobattle algobattle-problems/pairsum +algobattle test ``` -
- -///note -This should now run without error and display the match screen while doing so. Running the whole match may take a while, -if you don't want to wait you can cancel the execution with `CTRL+C`. -/// +This time it should run without any errors. If that doesn't work for you, there's error messages in the linked json file. diff --git a/docs/tutorial/summary.md b/docs/tutorial/summary.md new file mode 100644 index 00000000..e75ef1e7 --- /dev/null +++ b/docs/tutorial/summary.md @@ -0,0 +1,8 @@ +# Conclusion + +If you've made it all the way through the tutorial you're now probably ready to start coding things yourself! There's a +lot of stuff left to cover, but none of it is essential to get started and can be more easily understood once you're a +bit more familiar with the framework. Feel free to try things out and come back to either the tutorial, or the +[advanced topics](/advanced/index.md) whenever you're having questions. + +Hope you have fun with Algobattle! diff --git a/mkdocs.yml b/mkdocs.yml index d30784d9..3df3a921 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,8 @@ markdown_extensions: - pymdownx.extra - pymdownx.highlight - pymdownx.inlinehilite + - pymdownx.details + - pymdownx.superfences - pymdownx.blocks.tab: alternate_style: true - pymdownx.blocks.admonition: @@ -82,8 +84,13 @@ markdown_extensions: - quote - new - settings + - pymdownx.keys - mdx_include: base_path: docs/src + - attr_list + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg extra_css: @@ -98,12 +105,16 @@ nav: - Home: index.md - Tutorial: - tutorial/index.md - - tutorial/overview.md - tutorial/installation.md - - tutorial/match.md + - tutorial/getting_started.md - tutorial/programs.md - - tutorial/battle_types.md - - tutorial/config.md + - tutorial/match.md + - tutorial/summary.md + - Advanced Topics: + - advanced/index.md + - advanced/config.md + - advanced/battle_types.md + - advanced/docker.md - API Reference: - api/index.md - api/battle.md From 5baa6f265803c6dc0d7a3f4635d99f9c477d7160 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 13:32:08 +0200 Subject: [PATCH 106/113] fix tests --- tests/test_match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_match.py b/tests/test_match.py index 1b8773c1..e266502b 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -219,7 +219,7 @@ def setUpClass(cls) -> None: cls.teams = {"team_0": TeamInfo(generator=cls.problem_path / "generator", solver=cls.problem_path / "solver")} def test_no_cfg_default(self): - with self.assertRaises(ValueError): + with self.assertRaises(FileNotFoundError): AlgobattleConfig.from_file(self.problem_path) def test_empty_cfg(self): From 53993120ea45cdb42da43e4ac4eb857860ae5b28 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 13:33:22 +0200 Subject: [PATCH 107/113] fix formatting --- algobattle/templates/__init__.py | 2 +- docs/src/pairsum_generator/main.py | 2 +- docs/src/pairsum_generator/start.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index cfd0b6b7..6bf23ea8 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -58,7 +58,7 @@ def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: """Writes the formatted templates to the target directory.""" template_args = args | { "project": f"{normalize(args['team'])}-{normalize(args['problem'])}-{normalize(args['program'])}", - "team_normalized": args["team"].lower().replace(" ", "") + "team_normalized": args["team"].lower().replace(" ", ""), } for name in lang.env.list_templates(): template = lang.env.get_template(name) diff --git a/docs/src/pairsum_generator/main.py b/docs/src/pairsum_generator/main.py index 74ee9bf9..fb2a36b7 100644 --- a/docs/src/pairsum_generator/main.py +++ b/docs/src/pairsum_generator/main.py @@ -16,7 +16,7 @@ c = randrange(min(a + b, 2**63 - 1)) d = a + b - c -indices = sorted(sample(range(max_size), 4)) # (3)! +indices = sorted(sample(range(max_size), 4)) # (3)! for index, number in zip(indices, [a, b, c, d]): numbers.insert(index, number) diff --git a/docs/src/pairsum_generator/start.py b/docs/src/pairsum_generator/start.py index dcd8634b..74e3ff50 100644 --- a/docs/src/pairsum_generator/start.py +++ b/docs/src/pairsum_generator/start.py @@ -8,7 +8,7 @@ numbers = [randrange(2**63 - 1) for _ in range(max_size)] # (1)! instance = { - "numbers": numbers, # (2)! + "numbers": numbers, # (2)! } solution = ... From e349800194fa6b88a581f3dfac71e230b7c66aee Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 17:17:06 +0200 Subject: [PATCH 108/113] add size option to test command --- algobattle/cli.py | 3 ++- algobattle/program.py | 4 ++-- docs/advanced/config.md | 3 +++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index d0280020..36c892a9 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -389,6 +389,7 @@ class TestErrors(BaseModel): @app.command() def test( project: Annotated[Path, Argument(help="The project folder to use.")] = Path(), + size: Annotated[Optional[int], Option(help="The size of instance the generator will be asked to create.")] = None, ) -> None: """Tests whether the programs install successfully and run on dummy instances without crashing.""" if not (project.is_file() or project.joinpath("algobattle.toml").is_file()): @@ -413,7 +414,7 @@ async def gen_builder() -> Generator: with run_async_fn(gen_builder) as gen: console.print("[success]Generator built successfully") with console.status("Running generator"): - instance = gen.test() + instance = gen.test(size) if isinstance(instance, ExceptionInfo): console.print("[error]Generator didn't run successfully") errors.generator_run = instance diff --git a/algobattle/program.py b/algobattle/program.py index e7e4ea1b..47108503 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -581,9 +581,9 @@ async def run( solution=solution, ) - def test(self) -> Instance | ExceptionInfo: + def test(self, max_size: int | None = None) -> Instance | ExceptionInfo: """Tests whether the generator runs without issues and creates a syntactically valid instance.""" - res = run_async(self.run, self.problem.min_size) + res = run_async(self.run, max_size or self.problem.min_size) if res.info.error: return res.info.error else: diff --git a/docs/advanced/config.md b/docs/advanced/config.md index a436fc31..7256990e 100644 --- a/docs/advanced/config.md +++ b/docs/advanced/config.md @@ -255,6 +255,9 @@ This runs a basic test checking whether the programs in a project build and run : Path to the Algobattle project to test. Can either point directly to a project config file, or to a folder containing one called `algobattle.toml`. Defaults to the current working directory. +`--size` +: Will be passed to the generator as it's `max_size`. Defaults to the problem's minimum size. + ### config Opens the CLI config file. Accepts no arguments.1 From 0345ef01a413342a61dc9bca42b3e7975699dc71 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 17:25:01 +0200 Subject: [PATCH 109/113] add clarification about init behaviour --- docs/tutorial/programs.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/tutorial/programs.md b/docs/tutorial/programs.md index f0d8feeb..e4f6c87b 100644 --- a/docs/tutorial/programs.md +++ b/docs/tutorial/programs.md @@ -89,6 +89,10 @@ algobattle init --generator python the initially auto-generated file we made when we set up the project folder, so we can safely replace it with the python template. +!!! tip "No need to repeat yourself" + You can directly specify the languages you want to use when unpacking the problem spec file. We're only doing it in + several steps here to explain every part on its own. + Our project folder should now look something like this ``` { .sh .no-copy } From 9f85f61178d60b12287eafbf240bc3c3074bab64 Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 17:42:46 +0200 Subject: [PATCH 110/113] make prompts have consistend theming --- algobattle/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/algobattle/cli.py b/algobattle/cli.py index 36c892a9..4143ad63 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -138,6 +138,7 @@ def install_cmd(self) -> list[str]: "might be better.", default="normal", choices=["normal", "user"], + console=console, ) if command_str == "user": cmd.append("--user") @@ -196,6 +197,7 @@ def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: replace = Confirm.ask( f"[attention]The targeted directory already contains a {role}, do you want to replace it?", default=True, + console=console, ) if replace: rmtree(dir) @@ -286,6 +288,7 @@ def init( "[attention]The target directory already contains an algobattle project, " "do you want to replace it?", default=True, + console=console, ) else: copy_problem_data = True From b8adad3038448f5347647589bb9301b75e7f026f Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 17:51:53 +0200 Subject: [PATCH 111/113] nicer padding during match build --- algobattle/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 4143ad63..10a0a1ae 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -603,7 +603,7 @@ def __init__(self, teams: Iterable[str]) -> None: self.teams = { team: self.team_progress.add_task(team, start=False, total=2, status="", name=team) for team in teams } - super().__init__(self.overall_progress, self.team_progress) + super().__init__(Padding(self.overall_progress, (0, 0, 1, 0)), self.team_progress) class FightPanel(Panel): From 44d865a4e4a74357dbd444e40ab903109582ff7c Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 18:04:37 +0200 Subject: [PATCH 112/113] simplify build panel setup --- algobattle/cli.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 10a0a1ae..a85c12a4 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -603,7 +603,14 @@ def __init__(self, teams: Iterable[str]) -> None: self.teams = { team: self.team_progress.add_task(team, start=False, total=2, status="", name=team) for team in teams } - super().__init__(Padding(self.overall_progress, (0, 0, 1, 0)), self.team_progress) + super().__init__(*self._make_renderables()) + + def _make_renderables(self) -> list[RenderableType]: + return [ + Padding(self.overall_progress, (0, 0, 1, 0)), + self.team_progress, + ] + class FightPanel(Panel): @@ -683,15 +690,18 @@ class CliUi(Live, Ui): match: Match def __init__(self) -> None: + self.build: BuildView | None = None self.battle_panels: dict[Matchup, BattlePanel] = {} super().__init__(None, refresh_per_second=10, transient=True, console=console) def __enter__(self) -> Self: return cast(Self, super().__enter__()) - def _update_renderable(self, renderable: RenderableType | None = None) -> None: - if renderable is None: + def _update_renderable(self) -> None: + if self.build is None: renderable = Group(self.display_match(self.match), *self.battle_panels.values()) + else: + renderable = self.build self.update(Panel(renderable, title=f"[orange1]Algobattle {pkg_version('algobattle_base')}")) @staticmethod @@ -714,13 +724,13 @@ def display_match(match: Match) -> RenderableType: @override def start_build_step(self, teams: Iterable[str], timeout: float | None) -> None: - self._update_renderable(BuildView(teams)) + self.build = BuildView(teams) + self._update_renderable() @override def start_build(self, team: str, role: Role) -> None: - assert isinstance(self.renderable, Panel) - view = self.renderable.renderable - assert isinstance(view, BuildView) + view = self.build + assert view is not None task = view.teams[team] match role: case Role.generator: @@ -731,9 +741,8 @@ def start_build(self, team: str, role: Role) -> None: @override def finish_build(self, team: str, success: bool) -> None: - assert isinstance(self.renderable, Panel) - view = self.renderable.renderable - assert isinstance(view, BuildView) + view = self.build + assert view is not None task = view.teams[team] current = view.team_progress._tasks[task].completed view.team_progress.update(task, completed=2, status="" if success else "[error]failed!") From 7a3a901e1ffb92c33b81672f47a9745f5673619f Mon Sep 17 00:00:00 2001 From: Imogen Date: Tue, 19 Sep 2023 18:08:38 +0200 Subject: [PATCH 113/113] fix formatting --- algobattle/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index a85c12a4..f84d430e 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -612,7 +612,6 @@ def _make_renderables(self) -> list[RenderableType]: ] - class FightPanel(Panel): """Panel displaying a currently running fight."""