Skip to content

Commit

Permalink
Merge pull request #8 from sourcery-ai/sourcery-ai/issue-2
Browse files Browse the repository at this point in the history
feat: Implement Advent of Code CLI
  • Loading branch information
brendanator authored Dec 24, 2024
2 parents 3f875c2 + 7581399 commit a5f742b
Show file tree
Hide file tree
Showing 3 changed files with 335 additions and 0 deletions.
231 changes: 231 additions & 0 deletions advent_of_code.py
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()
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"httpx>=0.28.1",
"markdownify>=0.14.1",
"typer>=0.15.1",
"types-beautifulsoup4>=4.12.3",
]

[dependency-groups]
Expand Down
103 changes: 103 additions & 0 deletions tests/test_advent_of_code.py
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

0 comments on commit a5f742b

Please sign in to comment.