Skip to content

Commit

Permalink
Merge pull request #133 from ImogenBits/package
Browse files Browse the repository at this point in the history
Add problem packaging
  • Loading branch information
Benezivas authored Oct 8, 2023
2 parents 3fc624c + 906210d commit 2e7e692
Show file tree
Hide file tree
Showing 6 changed files with 106 additions and 83 deletions.
131 changes: 80 additions & 51 deletions algobattle/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
For more detailed documentation, visit our website at http://algobattle.org/docs/tutorial
"""
app = Typer(pretty_exceptions_show_locals=True, help=help_message)
packager = Typer(help="Subcommands to package problems and programs into `.algo` files.")
app.add_typer(packager, name="package")
theme = Theme(
{
"success": "green",
Expand Down Expand Up @@ -105,8 +107,6 @@ 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))

Expand Down Expand Up @@ -312,13 +312,10 @@ def init(
raise Abort

problem_name = parsed_config.match.problem
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:
if deps := parsed_config.problem.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
cmd + deps, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True
) as installer:
assert installer.stdout is not None
assert installer.stderr is not None
Expand Down Expand Up @@ -348,10 +345,12 @@ def init(
if not res_path.is_absolute():
res_path = target / res_path
res_path.mkdir(parents=True, exist_ok=True)
gitignore = "*.algo\n*.prob\n"
if res_path.resolve().is_relative_to(target.resolve()):
target.joinpath(".gitignore").write_text(f"{res_path.relative_to(target)}/\n")
gitignore += f"{res_path.relative_to(target)}/\n"
target.joinpath(".gitignore").write_text(gitignore)

problem_obj = parsed_config.problem
problem_obj = parsed_config.loaded_problem
if schemas:
instance: type[Instance] = problem_obj.instance_cls
solution: type[Solution[Instance]] = problem_obj.solution_cls
Expand Down Expand Up @@ -394,13 +393,13 @@ class TestErrors(BaseModel):
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:
) -> Literal["success", "error"]:
"""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()):
console.print("[error]The folder does not contain an Algobattle project")
raise Abort
config = AlgobattleConfig.from_file(project)
problem = config.problem
problem = config.loaded_problem
all_errors: dict[str, Any] = {}

for team, team_info in config.teams.items():
Expand Down Expand Up @@ -465,6 +464,9 @@ async def sol_builder() -> Solver:
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}")
return "error"
else:
return "success"


@app.command()
Expand All @@ -475,12 +477,9 @@ 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,
@packager.command("problem")
def package_problem(
project: Annotated[Path, Argument(exists=True, resolve_path=True, help="Path to the project directory.")] = Path(),
description: Annotated[
Optional[Path], Option(exists=True, dir_okay=False, help="Path to a problem description file.")
] = None,
Expand All @@ -489,22 +488,13 @@ def package(
] = 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("[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("[error]Couldn't find a config file")
raise Abort
if project.is_file():
config = project
project = project.parent
else:
config = project / "algobattle.toml"
if description is None:
match list(problem_path.parent.resolve().glob("description.*")):
match list(project.glob("description.*")):
case []:
pass
case [desc]:
Expand All @@ -519,46 +509,85 @@ def package(
config_doc = parse_toml(config.read_text())
parsed_config = AlgobattleConfig.from_file(config)
except (ValidationError, ParseError) as e:
console.print(f"[error]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)
parsed_config.loaded_problem
except (ValueError, RuntimeError) as e:
console.print(f"[error]Couldn't load the problem file[/]\nError: {e}")
raise Abort
problem_info = parsed_config.problems[problem_name]

if "project" in config_doc:
config_doc.remove("project")
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)
prob_table: Any = config_doc.get("problem", None)
if isinstance(prob_table, TomlTable) and "location" in prob_table:
prob_table.remove("location")
if len(prob_table) == 0:
config_doc.remove("problem")

if out is None:
out = problem_path.parent / f"{problem_name.lower().replace(' ', '_')}.algo"
out = project / 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))
if parsed_config.problem.location.exists():
file.write(parsed_config.problem.location, "problem.py")
file.writestr("algobattle.toml", dumps_toml(config_doc))
if description is not None:
file.write(description, description.name)
console.print("[success]Packaged Algobattle project[/] into", out)


@packager.command("programs")
def package_programs(
project: Annotated[Path, Argument(help="The project folder to use.")] = Path(),
team: Annotated[Optional[str], Option(help="Name of team whose programs should be packaged.")] = None,
generator: Annotated[bool, Option(help="Wether to package the generator")] = True,
solver: Annotated[bool, Option(help="Wether to package the solver")] = True,
test_programs: Annotated[
bool, Option("--test/--no-test", help="Whether to test the programs before packaging them")
] = True,
) -> None:
config = AlgobattleConfig.from_file(project)
if team is None:
match list(config.teams.keys()):
case []:
console.print("[error]The config file doesn't contain a team[/]")
raise Abort
case [name]:
team = name
case _:
console.print(
"[error]The Config file contains multiple teams[/], specify whose programs you want to package"
)
raise Abort
if team not in config.teams:
console.print("[erorr]The selected team isn't in the config file[/]")
raise Abort
if test_programs:
test_result = test(project)
if test_result == "error":
console.print("Stopping program packaging since they do not pass tests")
raise Abort
out = project.parent if project.is_file() else project

def _package_program(role: Role) -> None:
with console.status(f"Packaging {team}'s {role}"), ZipFile(out / f"{team} {role}.prog", "w") as zipfile:
program_root: Path = getattr(config.teams[team], role)
for file in program_root.rglob("*"):
if file.is_dir():
continue
zipfile.write(file, file.relative_to(program_root))
console.print(f"[success]Packaged {team}'s {role}")

if generator:
_package_program(Role.generator)
if solver:
_package_program(Role.solver)


class TimerTotalColumn(ProgressColumn):
"""Renders time elapsed."""

Expand Down
12 changes: 6 additions & 6 deletions algobattle/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ async def run(
if ui is None:
ui = EmptyUi()
ui.match = self
problem = Problem.load(config.match.problem, config.problems)
problem = config.loaded_problem

with await TeamHandler.build(config.teams, problem, config.as_prog_config(), ui) as teams:
self.active_teams = [t.name for t in teams.active]
Expand Down Expand Up @@ -571,7 +571,7 @@ class MatchConfig(BaseModel):
class DynamicProblemConfig(BaseModel):
"""Defines metadata used to dynamically import problems."""

location: RelativePath
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"""
Expand Down Expand Up @@ -620,23 +620,23 @@ class AlgobattleConfig(BaseModel):
project: ProjectConfig = Field(default_factory=dict, validate_default=True)
match: MatchConfig
docker: DockerConfig = DockerConfig()
problems: dict[str, DynamicProblemConfig] = Field(default_factory=dict)
problem: DynamicProblemConfig = Field(default_factory=dict, validate_default=True)

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():
if not self.problem.location.is_file() 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:
def loaded_problem(self) -> Problem:
"""The problem this config uses."""
return Problem.load(self.match.problem, self.problems)
return Problem.load(self.match.problem, self.problem.location if self.problem.location.is_file() else None)

@classmethod
def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, relativize_paths: bool = True) -> Self:
Expand Down
16 changes: 4 additions & 12 deletions algobattle/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
Callable,
ClassVar,
Literal,
Mapping,
ParamSpec,
Protocol,
Self,
Expand Down Expand Up @@ -215,12 +214,6 @@ 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:
"""The definition of a problem."""

Expand Down Expand Up @@ -347,20 +340,19 @@ def load_file(cls, name: str, file: Path) -> Self:
return cls._problems[name]

@classmethod
def load(cls, name: str, dynamic: Mapping[str, DynamicProblemInfo]) -> Self:
def load(cls, name: str, file: Path | None = None) -> Self:
"""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.
file: Path to a file containing this problem.
Raises:
ValueError: If the problem is not specified properly
RuntimeError: If the problem's dynamic import fails
"""
if name in dynamic:
info = dynamic[name]
return cls.load_file(name, info.location)
if file:
return cls.load_file(name, file)
if name in cls._problems:
return cls._problems[name]
match list(entry_points(group="algobattle.problem", name=name)):
Expand Down
6 changes: 3 additions & 3 deletions docs/advanced/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,15 @@ structure with both keys being mandatory:
`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:
### `problem`
: Contains data specifying how to dynamically import the problem.

!!! 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.
: Path to the problem module. Defaults to `problem.py`.

`dependencies`
: A list of [PEP 508](https://peps.python.org/pep-0508/) conformant dependency specification strings. These will be
Expand Down
11 changes: 1 addition & 10 deletions docs/tutorial/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,9 @@ Project level configuration is done inside the `algobattle.toml` file so let's t
problem = "Pairsum"
# there might be more settings here

[problems."Pairsum"]
location = "problem.py"

[teams."Red Pandas"]
generator = "generator"
solver = "solver"

[project]
results = "results"
```
///

Expand All @@ -129,16 +123,13 @@ 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
`problem`, 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.
Expand Down
Loading

0 comments on commit 2e7e692

Please sign in to comment.