Skip to content

Commit 7f0f7ac

Browse files
committed
Refactor the challenge interface to be able to provide custom input.
1 parent 9fd24da commit 7f0f7ac

File tree

8 files changed

+217
-59
lines changed

8 files changed

+217
-59
lines changed

src/aoc/base.py

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,81 @@
11
import abc
2-
import logging
32
from pathlib import Path
4-
from typing import Any
3+
from typing import Any, ClassVar
54

6-
import __main__
5+
from typing_extensions import Protocol
6+
7+
from aoc.input_providers import InputProvider
78

89
REPO_ROOT = Path(__file__).parent.parent.parent
9-
logger = logging.getLogger(__name__)
1010

1111

12-
class BaseChallenge(abc.ABC):
12+
def get_day_from_module(module_name: str) -> int:
13+
"""Return the day of this challenge based on the module name."""
14+
return int(module_name.split(".")[-1].split("_")[1])
15+
16+
17+
class ChallengeProtocol(Protocol):
18+
def part_1(self, input_lines: list[str]) -> Any:
19+
...
20+
21+
def part_2(self, input_lines: list[str]) -> Any:
22+
...
23+
24+
25+
class BaseChallenge(ChallengeProtocol, abc.ABC):
1326
"""Base class for all challenges."""
1427

15-
def __init__(self, use_test_data: bool = False, data_dir: Path | None = None):
16-
self._use_test_data = use_test_data
28+
day: ClassVar[int]
29+
30+
def __init__(
31+
self,
32+
input_provider: InputProvider,
33+
):
34+
self._input_provider = input_provider
1735
self._input_lines: dict[int | None, list[str]] = {}
18-
self._data_dir = data_dir or REPO_ROOT.joinpath("data")
1936

20-
@property
21-
def day(self) -> int:
37+
def __init_subclass__(cls, **kwargs):
38+
cls.day = cls._get_day()
39+
40+
@classmethod
41+
def _get_day(cls) -> int:
42+
import __main__
43+
2244
"""Return the day of this challenge based on the module name."""
23-
if self.__module__ == "__main__": # challenge is run directly
45+
if cls.__module__ == "__main__": # challenge is run directly
2446
return int(Path(__main__.__file__).parent.name.split("_")[1])
25-
return int(self.__module__.split(".")[-1].split("_")[1])
26-
27-
def get_input_filename(self, part: int | None = None) -> str:
28-
"""Return the input filename for this challenge."""
29-
base_filename = (
30-
f"{self.day:02}_test_input"
31-
if self._use_test_data
32-
else f"{self.day:02}_input"
33-
)
34-
default_filename = f"{base_filename}.txt"
35-
if part is not None:
36-
filename = f"{base_filename}_part_{part}.txt"
37-
if self._data_dir.joinpath(filename).exists():
38-
return filename
39-
else:
40-
logger.info(
41-
"File %s does not exist. Using default instead %s",
42-
filename,
43-
default_filename,
44-
)
45-
return default_filename
46-
47-
def get_input_file_path(self, filename: str) -> Path:
48-
"""Return the input filename for this challenge."""
49-
return self._data_dir.joinpath(filename)
47+
return get_day_from_module(cls.__module__)
5048

5149
def get_input_lines(self, part: int | None = None) -> list[str]:
5250
"""Return the input lines for this challenge. Relative to this file"""
53-
filename = self.get_input_filename(part)
54-
print(f"Using data from {filename}")
5551
if not self._input_lines.get(part):
5652
self._input_lines[part] = (
57-
self.get_input_file_path(filename).read_text().splitlines()
53+
self._input_provider.provide_input(part).strip().split("\n")
5854
)
5955
return self._input_lines[part]
6056

6157
def set_input_lines(self, lines: list[str], part: int | None = None):
6258
self._input_lines[part] = lines
6359

6460
@abc.abstractmethod
65-
def part_1(self) -> Any:
61+
def part_1(self, input_lines: list[str]) -> Any:
6662
"""Return the solution for part 1 of this challenge."""
6763
...
6864

6965
@abc.abstractmethod
70-
def part_2(self) -> Any:
66+
def part_2(self, input_lines: list[str]) -> Any:
7167
"""Return the solution for part 2 of this challenge."""
7268
...
7369

7470
def solve(self) -> tuple[Any, Any]:
7571
"""Return solutions for this challenge as a 2 element tuple."""
76-
return self.part_1(), self.part_2()
72+
return self.part_1(self.get_input_lines(part=1)), self.part_2(
73+
self.get_input_lines(part=2)
74+
)
7775

7876
def run(self):
79-
solution1 = self.part_1()
77+
solution1 = self.part_1(self.get_input_lines(part=1))
8078
print(f"Day {self.day} - Part 1: {solution1}")
81-
solution2 = self.part_2()
79+
solution2 = self.part_2(self.get_input_lines(part=2))
8280
print(f"Day {self.day} - Part 2: {solution2}\n")
8381
return solution1, solution2

src/aoc/base_tests.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from aoc.base import BaseChallenge
6+
from aoc.input_providers import SmartFileInputProvider
67

78

89
class Empty:
@@ -39,19 +40,32 @@ def __init_subclass__(cls, **kwargs):
3940
def test_on_sample_data_part_1(self):
4041
if (expected_result := self.expected_results_from_test_data[0]) == Empty:
4142
pytest.skip("No expected result for part one of test data set.")
42-
assert self.challenge_class(use_test_data=True).part_1() == expected_result
43+
44+
challenge = self.challenge_class(
45+
SmartFileInputProvider(self.challenge_class.day, use_test_data=True)
46+
)
47+
assert challenge.part_1(challenge.get_input_lines(part=1)) == expected_result
4348

4449
def test_on_sample_data_part_2(self):
4550
if (expected_result := self.expected_results_from_test_data[1]) == Empty:
4651
pytest.skip("No expected results for part two of test data set.")
47-
assert self.challenge_class(use_test_data=True).part_2() == expected_result
52+
challenge = self.challenge_class(
53+
SmartFileInputProvider(self.challenge_class.day, use_test_data=True)
54+
)
55+
assert challenge.part_2(challenge.get_input_lines(part=2)) == expected_result
4856

4957
def test_on_real_data_part_1(self):
5058
if (expected_result := self.expected_results_from_real_data[0]) == Empty:
5159
pytest.skip("No expected result for part one of real data set.")
52-
assert self.challenge_class().part_1() == expected_result
60+
challenge = self.challenge_class(
61+
SmartFileInputProvider(self.challenge_class.day)
62+
)
63+
assert challenge.part_1(challenge.get_input_lines(part=1)) == expected_result
5364

5465
def test_on_real_data_part_2(self):
5566
if (expected_result := self.expected_results_from_real_data[1]) == Empty:
5667
pytest.skip("No expected results for part two of real data set.")
57-
assert self.challenge_class().part_2() == expected_result
68+
challenge = self.challenge_class(
69+
SmartFileInputProvider(self.challenge_class.day)
70+
)
71+
assert challenge.part_2(challenge.get_input_lines(part=2)) == expected_result

src/aoc/input_providers.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import abc
2+
from dataclasses import dataclass
3+
from pathlib import Path
4+
5+
from aoc.logger import logger
6+
7+
8+
@dataclass
9+
class InputProvider(abc.ABC):
10+
day: int
11+
12+
@abc.abstractmethod
13+
def provide_input(self, part: int | None) -> str:
14+
...
15+
16+
@property
17+
def _day(self) -> int:
18+
import __main__
19+
20+
"""Return the day of this challenge based on the module name."""
21+
if self.__module__ == "__main__": # challenge is run directly
22+
return int(Path(__main__.__file__).parent.name.split("_")[1])
23+
return int(self.__module__.split(".")[-1].split("_")[1])
24+
25+
26+
@dataclass
27+
class SmartFileInputProvider(InputProvider):
28+
use_test_data: bool = False
29+
30+
def __post_init__(self):
31+
self._data_dir = Path(__file__).parents[2].joinpath("data")
32+
33+
def provide_input(self, part: int | None) -> str:
34+
filename = self.get_input_filename(part)
35+
print("Using data from", filename)
36+
return self.get_input_file_path(self.get_input_filename(part)).read_text()
37+
38+
def get_input_filename(self, part: int | None = None) -> str:
39+
"""Return the input filename for this challenge."""
40+
base_filename = (
41+
f"{self.day:02}_test_input"
42+
if self.use_test_data
43+
else f"{self.day:02}_input"
44+
)
45+
default_filename = f"{base_filename}.txt"
46+
if part is not None:
47+
filename = f"{base_filename}_part_{part}.txt"
48+
if self._data_dir.joinpath(filename).exists():
49+
return filename
50+
else:
51+
logger.info(
52+
"File %s does not exist. Using default instead %s",
53+
filename,
54+
default_filename,
55+
)
56+
return default_filename
57+
58+
def get_input_file_path(self, filename: str) -> Path:
59+
"""Return the input filename for this challenge."""
60+
return self._data_dir.joinpath(filename)
61+
62+
63+
@dataclass
64+
class SingleFileInputProvider(InputProvider):
65+
input_path: Path
66+
67+
def provide_input(self, part: int | None) -> str:
68+
if not self.input_path.exists():
69+
raise FileNotFoundError(f"File {self.input_path.resolve()} does not exist.")
70+
print("Using data from", self.input_path.name)
71+
return self.input_path.read_text()

src/aoc/logger.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import logging
2+
3+
logger = logging.getLogger(__name__)

src/aoc/main.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from aocd.models import Puzzle
1313
from mypy.nodes import TypeGuard
1414

15+
from aoc.input_providers import SingleFileInputProvider, SmartFileInputProvider
16+
1517
app = typer.Typer(no_args_is_help=True)
1618

1719

@@ -53,12 +55,17 @@ def import_challenge_module(day: int):
5355
return module
5456

5557

56-
def run_challenge(day: int, test_data: bool):
58+
def run_challenge(day: int, test_data: bool, input_path: Path | None = None):
5759
module = import_challenge_module(day)
60+
input_provider = (
61+
SingleFileInputProvider(day=day, input_path=input_path)
62+
if input_path
63+
else SmartFileInputProvider(day=day, use_test_data=test_data)
64+
)
5865
if test_data:
59-
module.Challenge(use_test_data=True).run()
66+
module.Challenge(input_provider=input_provider).run()
6067
else:
61-
module.Challenge(use_test_data=False).run()
68+
module.Challenge(input_provider=input_provider).run()
6269

6370

6471
def get_puzzle_object(year: int, day: int) -> Puzzle | None:
@@ -92,13 +99,16 @@ def write_input_data(puzzle, real_input_data):
9299

93100
@app.command()
94101
def run(
95-
day: int = typer.Argument(..., help="Day of the challenge to run."),
102+
day: Annotated[int, typer.Argument(..., help="Day of the challenge to run.")],
103+
file: Annotated[
104+
Path | None, typer.Option(..., "--file", "-f", help="File to run.")
105+
] = None,
96106
test_data: bool = typer.Option(
97107
False, "--test-data", "-t", help="Run challenge also for test data."
98108
),
99109
):
100110
"""Run the challenge."""
101-
run_challenge(day, test_data)
111+
run_challenge(day, test_data, file)
102112

103113

104114
@app.command()
@@ -209,11 +219,11 @@ def new_day(
209219
"--directory",
210220
help="Path to a directory with challenges",
211221
),
212-
] = "src/aoc",
222+
] = Path("src/aoc"),
213223
data_directory: Annotated[
214224
Path,
215225
typer.Option(help="Path to a directory with data."),
216-
] = "data",
226+
] = Path("data"),
217227
template_directory: Annotated[
218228
Path,
219229
typer.Option(
Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1+
import sys
2+
from pathlib import Path
3+
14
from aoc.base import BaseChallenge
5+
from aoc.input_providers import SingleFileInputProvider, SmartFileInputProvider
26

37

48
class Challenge(BaseChallenge):
5-
def part_1(self):
9+
def part_1(self, input_lines: list[str]) -> int | str:
610
return
711

8-
def part_2(self):
12+
def part_2(self, input_lines: list[str]) -> int | str:
913
return
1014

1115

1216
if __name__ == "__main__":
13-
Challenge().run()
14-
Challenge(use_test_data=True).run()
17+
if len(sys.argv) > 1:
18+
input_provider = SingleFileInputProvider(
19+
Challenge.day, input_path=Path(sys.argv[1])
20+
)
21+
Challenge(input_provider).run()
22+
else:
23+
Challenge(SmartFileInputProvider(Challenge.day, use_test_data=True)).run()
24+
Challenge(SmartFileInputProvider(Challenge.day)).run()

tests/data/custom_input.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
10
2+
11
3+
12

tests/test_cli.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,52 @@ def test_setting_up_new_day_creates_directories_in_configured_places(
4040
assert (tmp_path / "data" / "11_input.txt").read_text() == "1"
4141
assert (tmp_path / "data" / "11_test_input.txt").read_text() == "2"
4242
assert (tmp_path / "src" / "day_11").exists()
43+
44+
45+
class TestRunningSolution:
46+
def test_run_for_a_specific_day_with_default_data(self, tmp_path: Path):
47+
result = runner.invoke(
48+
app,
49+
[
50+
"run",
51+
"0",
52+
],
53+
)
54+
if result.exception:
55+
raise result.exception
56+
assert result.exit_code == 0, result.exc_info
57+
assert "Day 0 - Part 1: 1" in result.stdout
58+
assert "Day 0 - Part 2: 55" in result.stdout
59+
60+
def test_run_for_a_specific_day_with_test_data(self, tmp_path: Path):
61+
result = runner.invoke(
62+
app,
63+
[
64+
"run",
65+
"0",
66+
"-t",
67+
],
68+
)
69+
if result.exception:
70+
raise result.exception
71+
assert result.exit_code == 0, result.exc_info
72+
assert "00_test_input.txt" in result.stdout
73+
assert "Day 0 - Part 1: 1" in result.stdout
74+
assert "Day 0 - Part 2: 21" in result.stdout
75+
76+
def test_run_for_a_specific_day_with_a_custom_file(self, tmp_path: Path):
77+
result = runner.invoke(
78+
app,
79+
[
80+
"run",
81+
"0",
82+
"-f",
83+
Path(__file__).parent / "data/custom_input.txt",
84+
],
85+
)
86+
if result.exception:
87+
raise result.exception
88+
assert result.exit_code == 0, result.exc_info
89+
assert "Using data from custom_input.txt" in result.stdout
90+
assert "Day 0 - Part 1: 1" in result.stdout
91+
assert "Day 0 - Part 2: 33" in result.stdout

0 commit comments

Comments
 (0)