-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #8 from sourcery-ai/sourcery-ai/issue-2
feat: Implement Advent of Code CLI
- Loading branch information
Showing
3 changed files
with
335 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
import codecs | ||
import os | ||
import re | ||
from pathlib import Path | ||
from typing import Annotated, Optional | ||
|
||
import httpx | ||
import typer | ||
from bs4 import BeautifulSoup | ||
from markdownify import markdownify as md | ||
|
||
app = typer.Typer() | ||
|
||
|
||
def get_session() -> str: | ||
"""Get AOC session from environment variable.""" | ||
session = os.getenv("AOC_SESSION") | ||
if not session: | ||
raise typer.BadParameter("AOC_SESSION environment variable not set") | ||
return session | ||
|
||
|
||
def get_headers(session: str) -> dict[str, str]: | ||
"""Get request headers with session cookie.""" | ||
return { | ||
"Cookie": f"session={session}", | ||
"User-Agent": "github.com/sourcery-ai/autonomous-advent-of-code", | ||
} | ||
|
||
|
||
def rot13_encode(text: str) -> str: | ||
"""ROT13 encode text.""" | ||
return codecs.encode(text, "rot13") | ||
|
||
|
||
def rot13_decode(text: str) -> str: | ||
"""ROT13 decode text.""" | ||
return codecs.decode(text, "rot13") | ||
|
||
|
||
def ensure_puzzle_dir(year: int, day: int) -> Path: | ||
"""Ensure puzzle directory exists and return path.""" | ||
puzzle_dir = Path(f"puzzles/year_{year}/day_{day:02d}") | ||
puzzle_dir.mkdir(parents=True, exist_ok=True) | ||
return puzzle_dir | ||
|
||
|
||
def extract_parts(html: str) -> tuple[str, Optional[str]]: | ||
"""Extract puzzle parts from HTML content.""" | ||
soup = BeautifulSoup(html, "html.parser") | ||
article = soup.find("article") | ||
if not article: | ||
raise typer.BadParameter("Could not find puzzle content") | ||
|
||
# Convert to markdown and split on "--- Part Two ---" | ||
markdown = md(str(article)) | ||
parts = markdown.split("--- Part Two ---") | ||
|
||
part1 = parts[0].strip() | ||
part2 = parts[1].strip() if len(parts) > 1 else None | ||
|
||
return part1, part2 | ||
|
||
|
||
@app.command() | ||
def download( | ||
year: Annotated[int, typer.Argument(help="Year of puzzle")], | ||
day: Annotated[int, typer.Argument(help="Day of puzzle")], | ||
) -> None: | ||
"""Download puzzle content and input.""" | ||
session = get_session() | ||
headers = get_headers(session) | ||
|
||
# Fetch puzzle content | ||
url = f"https://adventofcode.com/{year}/day/{day}" | ||
response = httpx.get(url, headers=headers, follow_redirects=True) | ||
response.raise_for_status() | ||
|
||
# Extract parts | ||
part1, part2 = extract_parts(response.text) | ||
|
||
# Create puzzle directory | ||
puzzle_dir = ensure_puzzle_dir(year, day) | ||
|
||
# Save part 1 | ||
part1_path = puzzle_dir / "question_part1_rot13.md" | ||
part1_path.write_text(rot13_encode(part1)) | ||
|
||
# Save part 2 if it exists | ||
if part2: | ||
part2_path = puzzle_dir / "question_part2_rot13.md" | ||
combined = f"{part1}\n\n--- Part Two ---\n\n{part2}" | ||
part2_path.write_text(rot13_encode(combined)) | ||
|
||
# Fetch and save input | ||
input_url = f"{url}/input" | ||
input_response = httpx.get(input_url, headers=headers, follow_redirects=True) | ||
input_response.raise_for_status() | ||
|
||
input_path = puzzle_dir / "input.txt" | ||
input_path.write_text(input_response.text) | ||
|
||
print(2 if part2 else 1) | ||
|
||
|
||
@app.command() | ||
def read( | ||
year: Annotated[int, typer.Argument(help="Year of puzzle")], | ||
day: Annotated[int, typer.Argument(help="Day of puzzle")], | ||
part: Annotated[int, typer.Argument(help="Part number (1 or 2)")], | ||
) -> None: | ||
"""Read puzzle content.""" | ||
puzzle_dir = ensure_puzzle_dir(year, day) | ||
question_path = puzzle_dir / f"question_part{part}_rot13.md" | ||
|
||
if not question_path.exists(): | ||
raise typer.BadParameter(f"Question file not found: {question_path}") | ||
|
||
encoded_content = question_path.read_text() | ||
decoded_content = rot13_decode(encoded_content) | ||
print(decoded_content) | ||
|
||
|
||
@app.command() | ||
def submit( | ||
year: Annotated[int, typer.Argument(help="Year of puzzle")], | ||
day: Annotated[int, typer.Argument(help="Day of puzzle")], | ||
part: Annotated[int, typer.Argument(help="Part number (1 or 2)")], | ||
) -> int: | ||
"""Submit answer for puzzle.""" | ||
puzzle_dir = ensure_puzzle_dir(year, day) | ||
answer_path = puzzle_dir / f"answer_part{part}.txt" | ||
|
||
if not answer_path.exists(): | ||
raise typer.BadParameter(f"Answer file not found: {answer_path}") | ||
|
||
session = get_session() | ||
headers = get_headers(session) | ||
|
||
answer = answer_path.read_text().strip() | ||
|
||
# Submit answer | ||
url = f"https://adventofcode.com/{year}/day/{day}/answer" | ||
data = {"level": str(part), "answer": answer} | ||
response = httpx.post(url, headers=headers, data=data, follow_redirects=True) | ||
response.raise_for_status() | ||
|
||
# Extract response content | ||
soup = BeautifulSoup(response.text, "html.parser") | ||
article = soup.find("article") | ||
if not article: | ||
raise typer.BadParameter("Could not find response content") | ||
|
||
result_markdown = md(str(article)) | ||
|
||
# Save response | ||
result_path = puzzle_dir / f"result_part{part}.md" | ||
result_path.write_text(result_markdown) | ||
|
||
# Print response | ||
print(result_markdown) | ||
|
||
return 0 if "That's the right answer" in result_markdown else 1 | ||
|
||
|
||
@app.command() | ||
def update_results( | ||
year: Annotated[int, typer.Argument(help="Year of puzzle")], | ||
) -> None: | ||
"""Update results table in README.""" | ||
results: dict[int, dict[int, str]] = {} | ||
puzzles_dir = Path(f"puzzles/year_{year}") | ||
|
||
if not puzzles_dir.exists(): | ||
raise typer.BadParameter(f"No puzzles found for year {year}") | ||
|
||
# Collect results for each day | ||
for day_dir in sorted(puzzles_dir.glob("day_*")): | ||
day = int(day_dir.name.replace("day_", "")) | ||
results[day] = {1: "NOT_SUBMITTED", 2: "NOT_SUBMITTED"} | ||
|
||
for part in [1, 2]: | ||
answer_file = day_dir / f"answer_part{part}.txt" | ||
result_file = day_dir / f"result_part{part}.md" | ||
|
||
if answer_file.exists() and result_file.exists(): | ||
result_content = result_file.read_text() | ||
if "That's the right answer" in result_content: | ||
results[day][part] = "CORRECT" | ||
else: | ||
results[day][part] = "INCORRECT" | ||
|
||
# Generate results table | ||
table_lines = [ | ||
"| Day | Part 1 | Part 2 |", | ||
"|-----|--------|--------|", | ||
] | ||
|
||
max_day = max(results.keys()) | ||
for day in range(1, max_day + 1): | ||
day_results = results.get(day, {1: "NOT_SUBMITTED", 2: "NOT_SUBMITTED"}) | ||
symbols = { | ||
"NOT_SUBMITTED": "", | ||
"INCORRECT": "❌", | ||
"CORRECT": "✅", | ||
} | ||
table_lines.append( | ||
f"| {day:2d} | {symbols[day_results[1]]:8} | {symbols[day_results[2]]:8} |" | ||
) | ||
|
||
table = "\n".join(table_lines) | ||
|
||
# Update README.md | ||
readme_path = Path("README.md") | ||
if not readme_path.exists(): | ||
readme_path.write_text("") | ||
|
||
content = readme_path.read_text() | ||
pattern = f"<!-- begin-results: {year} -->.*?<!-- end-results: {year} -->" | ||
replacement = f"<!-- begin-results: {year} -->\n{table}\n<!-- end-results: {year} -->" | ||
|
||
if re.search(pattern, content, re.DOTALL): | ||
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL) | ||
else: | ||
new_content = f"{content}\n\n{replacement}\n" | ||
|
||
readme_path.write_text(new_content) | ||
|
||
|
||
if __name__ == "__main__": | ||
app() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import os | ||
from pathlib import Path | ||
from typing import Generator | ||
|
||
import pytest | ||
from typer.testing import CliRunner | ||
|
||
from advent_of_code import app, extract_parts, rot13_decode, rot13_encode | ||
|
||
runner = CliRunner() | ||
|
||
|
||
@pytest.fixture | ||
def temp_puzzle_dir(tmp_path: Path) -> Generator[Path, None, None]: | ||
"""Create temporary puzzle directory.""" | ||
original_cwd = os.getcwd() | ||
os.chdir(tmp_path) | ||
yield tmp_path | ||
os.chdir(original_cwd) | ||
|
||
|
||
def test_rot13_encode_decode() -> None: | ||
"""Test ROT13 encoding and decoding.""" | ||
text = "Hello, World!" | ||
encoded = rot13_encode(text) | ||
assert encoded != text | ||
decoded = rot13_decode(encoded) | ||
assert decoded == text | ||
|
||
|
||
def test_extract_parts_part1_only() -> None: | ||
"""Test extracting only part 1 from HTML.""" | ||
html = """ | ||
<article> | ||
<h2>--- Day 1: Test ---</h2> | ||
<p>Part 1 content</p> | ||
</article> | ||
""" | ||
part1, part2 = extract_parts(html) | ||
assert "Part 1 content" in part1 | ||
assert part2 is None | ||
|
||
|
||
def test_extract_parts_both_parts() -> None: | ||
"""Test extracting both parts from HTML.""" | ||
html = """ | ||
<article> | ||
<h2>--- Day 1: Test ---</h2> | ||
<p>Part 1 content</p> | ||
<h2>--- Part Two ---</h2> | ||
<p>Part 2 content</p> | ||
</article> | ||
""" | ||
part1, part2 = extract_parts(html) | ||
assert "Part 1 content" in part1 | ||
assert "Part 2 content" in part2 | ||
|
||
|
||
def test_download_command_no_session(temp_puzzle_dir: Path) -> None: | ||
"""Test download command fails without session.""" | ||
result = runner.invoke(app, ["download", "2023", "1"]) | ||
assert result.exit_code == 2 | ||
assert "AOC_SESSION environment variable not set" in result.stdout | ||
|
||
|
||
def test_read_command_missing_file(temp_puzzle_dir: Path) -> None: | ||
"""Test read command fails with missing file.""" | ||
result = runner.invoke(app, ["read", "2023", "1", "1"]) | ||
assert result.exit_code != 0 | ||
assert "Question file not found" in result.stdout | ||
|
||
|
||
def test_submit_command_missing_answer(temp_puzzle_dir: Path) -> None: | ||
"""Test submit command fails with missing answer file.""" | ||
result = runner.invoke(app, ["submit", "2023", "1", "1"]) | ||
assert result.exit_code != 0 | ||
assert "Answer file not found" in result.stdout | ||
|
||
|
||
def test_update_results_command_no_puzzles(temp_puzzle_dir: Path) -> None: | ||
"""Test update-results command fails with no puzzles.""" | ||
result = runner.invoke(app, ["update-results", "2023"]) | ||
assert result.exit_code != 0 | ||
assert "No puzzles found for year 2023" in result.stdout | ||
|
||
|
||
def test_update_results_command_creates_table(temp_puzzle_dir: Path) -> None: | ||
"""Test update-results command creates results table.""" | ||
# Create puzzle directory with some results | ||
puzzle_dir = temp_puzzle_dir / "puzzles" / "year_2023" / "day_01" | ||
puzzle_dir.mkdir(parents=True) | ||
|
||
# Create answer and result files | ||
(puzzle_dir / "answer_part1.txt").write_text("42") | ||
(puzzle_dir / "result_part1.md").write_text("That's the right answer!") | ||
|
||
result = runner.invoke(app, ["update-results", "2023"]) | ||
assert result.exit_code == 0 | ||
|
||
# Check README.md was created with table | ||
readme = Path("README.md").read_text() | ||
assert "| Day | Part 1 | Part 2 |" in readme | ||
assert "| 1 | ✅" in readme |