From 0000000094fa42f03577adf7acf7e1f897dccb0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Sun, 30 Nov 2025 23:05:29 +0100 Subject: [PATCH] feat: initial commit --- .env.example | 1 + .gitignore | 220 ++++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 7 ++ LICENSE | 21 ++++ README.md | 27 +++++ aoc/__init__.py | 0 aoc/cli.py | 71 +++++++++++++ aoc/exceptions.py | 2 + aoc/input_file.py | 126 +++++++++++++++++++++++ aoc/run.py | 84 +++++++++++++++ conftest.py | 15 +++ data/example/.keep | 0 data/input/.keep | 0 dev-requirements.txt | 3 + main.py | 51 ++++++++++ pyproject.toml | 20 ++++ requirements.txt | 2 + src/__init__.py | 0 18 files changed, 650 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 aoc/__init__.py create mode 100644 aoc/cli.py create mode 100644 aoc/exceptions.py create mode 100644 aoc/input_file.py create mode 100644 aoc/run.py create mode 100644 conftest.py create mode 100644 data/example/.keep create mode 100644 data/input/.keep create mode 100644 dev-requirements.txt create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/__init__.py diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..d6bb48c --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +AOC_SESSION_TOKEN=token diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..d805552 --- /dev/null +++ b/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$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 + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.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 +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.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/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# advent of code +data/input/* +!data/input/.keep diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..470faca --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.7 + hooks: + - id: ruff + - id: ruff-format + args: [ --check ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..b4fdd8f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Matej Janežič + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..1a78935 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Advent-of-Code + +## Project overview + +### Project structure +- `data/` : + - `example/`: example files go here + - `input/`: this directory is gitignored, input files go here +- `aoc/` : cli implementation +- `src/` : + - `solution/year_/day_.py`: solution files +- `.env.example`: example dotenv file + +### cli +- `python main.py create [-y ] `: prepare solution files for `day` +- `python main.py download [-y ] `: download input file for `day` +- `python main.py solve [-y ] `: run solution against input for `day` + +### dotenv + +set `AOC_SESSION_TOKEN` to AoC session Cookie + +### FAQ + +#### How are your commits numbered in ascending order? +[https://westling.dev/b/extremely-linear-git](https://westling.dev/b/extremely-linear-git) + diff --git a/aoc/__init__.py b/aoc/__init__.py new file mode 100644 index 000000000..e69de29 diff --git a/aoc/cli.py b/aoc/cli.py new file mode 100644 index 000000000..ab2d175 --- /dev/null +++ b/aoc/cli.py @@ -0,0 +1,71 @@ +import argparse +import datetime + + +def get_latest_event_year() -> int: + now = datetime.datetime.now() + year = now.year + + if now.month < 12: + year -= 1 + + return year + + +def argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="aoc") + + subcli = parser.add_subparsers(title="command", required=True, dest="command") + + solve = subcli.add_parser("solve", help="solve a given day's puzzle") + solve.add_argument( + "-e", + "--example", + action="store_true", + required=False, + help="use example input file instead of real input file", + ) + solve.add_argument( + "-y", + "--year", + type=int, + required=False, + help="year of the puzzle (default: current year)", + default=get_latest_event_year(), + ) + solve.add_argument("day", type=int, help="day of the puzzle (1-25)") + + create = subcli.add_parser( + "create", help="create template and input files for a given day's puzzle" + ) + create.add_argument( + "-y", + "--year", + type=int, + required=False, + help="year of the puzzle (default: current year)", + default=get_latest_event_year(), + ) + create.add_argument("day", type=int, help="day of the puzzle (1-25)") + + download = subcli.add_parser( + "download", help="download input file for a given day's puzzle" + ) + download.add_argument( + "-f", + "--force", + action="store_true", + required=False, + help="force re-download even if input file already exists", + ) + download.add_argument( + "-y", + "--year", + type=int, + required=False, + help="year of the puzzle (default: current year)", + default=get_latest_event_year(), + ) + download.add_argument("day", type=int, help="day of the puzzle (1-25)") + + return parser diff --git a/aoc/exceptions.py b/aoc/exceptions.py new file mode 100644 index 000000000..0bcf3df --- /dev/null +++ b/aoc/exceptions.py @@ -0,0 +1,2 @@ +class AocError(Exception): + pass diff --git a/aoc/input_file.py b/aoc/input_file.py new file mode 100644 index 000000000..0096f12 --- /dev/null +++ b/aoc/input_file.py @@ -0,0 +1,126 @@ +import pathlib + +import requests + +from aoc import exceptions + + +def download(year: int, day: int, session_token: str | None) -> bytes: + url = f"https://adventofcode.com/{year}/day/{day}/input" + + cookies = {} + if session_token is not None: + cookies["session"] = session_token + + response = requests.get(url, cookies=cookies) + + if response.status_code != 200: + raise exceptions.AocError( + f"failed to fetch input file {year}/{day:02} (http:{response.status_code})" + " - is AOC_SESSION_TOKEN environment variable set?" + ) + + return response.content + + +def create_input_file( + year: int, + day: int, + session_token: str | None, + path_base: pathlib.Path, + force: bool = False, + exist_ok: bool = False, +): + path = path_base / str(year) / f"{day:02}.txt" + path.parent.mkdir(parents=True, exist_ok=True) + + if path.exists() and not path.stat().st_size == 0: + if exist_ok: + return + if not force: + raise exceptions.AocError(f"input file {path} already exists") + + input_data = download(year, day, session_token) + with open(path, "wb") as f: + f.write(input_data) + + +def get( + year: int, + day: int, + path_base: pathlib.Path, +) -> str: + path = path_base / str(year) / f"{day:02}.txt" + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path) as f: + return f.read() + + +def get_or_download( + year: int, + day: int, + session_token: str | None, + path_base: pathlib.Path, +) -> str: + path = path_base / str(year) / f"{day:02}.txt" + path.parent.mkdir(parents=True, exist_ok=True) + + create_input_file(year, day, session_token, path_base, exist_ok=True) + return get(year, day, path_base) + + +def _write_file_if_not_exists( + path: pathlib.Path, exist_ok: bool = False, data: bytes | None = None +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + if path.exists() and not exist_ok: + raise exceptions.AocError(f"{path} already exists") + + path.touch() + + if data is None: + return + + with open(path, "wb") as f: + f.write(data) + + +_CREATE_TEMPLATE: bytes = b"""\ +from typing import Any + + +def part_1(input_data: str) -> Any: + # your part 1 solution here + return None + + +def part_2(input_data: str) -> Any: + # your part 2 solution here + return None + + +# alternatively you can implement a "two in one solution" like this +# part_1 and part_2 must be removed or commented out in this case +# if tuple or list is returned elements will be printed on separate lines +# def part_1_2(input_data) -> Any: +# return None +""" + + +def create( + year: int, + day: int, + solution_base: pathlib.Path, + input_base: pathlib.Path, + example_base: pathlib.Path, +) -> None: + year_path = solution_base / f"year_{year}" + + year_path.mkdir(parents=True, exist_ok=True) + _write_file_if_not_exists(year_path / "__init__.py", exist_ok=True) + _write_file_if_not_exists(year_path / f"day_{day:02}.py", data=_CREATE_TEMPLATE) + + _write_file_if_not_exists(input_base / str(year) / f"{day:02}.txt") + _write_file_if_not_exists(example_base / str(year) / f"{day:02}.txt") diff --git a/aoc/run.py b/aoc/run.py new file mode 100644 index 000000000..b96549d --- /dev/null +++ b/aoc/run.py @@ -0,0 +1,84 @@ +import importlib.util +import pathlib +import time +from collections.abc import Callable +from typing import Any + +from aoc import exceptions + +_ANSI_ITALIC: str = "\x1b[3m" +_ANSI_BOLD: str = "\x1b[1m" +_ANSI_RESET: str = "\x1b[0m" + + +def _pprint_ns(ns: int) -> str: + if ns >= 1_000_000_000: + s = ns / 1_000_000_000 + return f"{s:.2f}s" + elif ns >= 1_000_000: + ms = ns / 1_000_000 + return f"{ms:.2f}ms" + elif ns >= 1_000: + us = ns / 1_000 + return f"{us:.2f}µs" + else: + return f"{ns}ns" + + +def _pprint_result( + year: int, day: int, part: int | str, result: Any, duration_ns: int +) -> None: + part = f"{_ANSI_BOLD}{year}/{day:02}/{part}{_ANSI_RESET}" + + if result is None: + solved = f"{_ANSI_ITALIC}(unsolved){_ANSI_RESET}" + print(f"{part}: {solved}") + else: + if isinstance(result, list) or isinstance(result, tuple): + result = "\n".join(str(r) for r in result) + solved = f"{_ANSI_ITALIC}(elapsed: {_pprint_ns(duration_ns)}){_ANSI_RESET}" + print(f"{part}: {solved}\n{result}") + + +def _run_func( + f: Callable[[str], Any], year: int, day: int, part: int | str, input_data: str +) -> None: + t_1 = time.perf_counter_ns() + result = f(input_data) + t_2 = time.perf_counter_ns() + + _pprint_result(year, day, part, result, t_2 - t_1) + + +def run_day(year: int, day: int, input_data: str, path_base: pathlib.Path) -> None: + try: + module_path = path_base / f"year_{year}" / f"day_{day:02}.py" + spec = importlib.util.spec_from_file_location( + f"year_{year}.day_{day:02}", module_path + ) + if spec is None or spec.loader is None: + raise exceptions.AocError() + solution_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(solution_module) + except (ModuleNotFoundError, exceptions.AocError) as e: + raise exceptions.AocError( + f"solution module for {year}/{day:02} not found: run 'python main.py create --year {year} {day}'" + ) from e + + p_1 = getattr(solution_module, "part_1", None) + p_2 = getattr(solution_module, "part_2", None) + + if p_1 is not None and p_2 is not None: + _run_func(p_1, year, day, 1, input_data) + _run_func(p_2, year, day, 2, input_data) + return + + p_1_2 = getattr(solution_module, "part_1_2", None) + + if p_1_2 is not None: + _run_func(p_1_2, year, day, "1&2", input_data) + return + + raise exceptions.AocError( + f"{year}/{day:02} must define part_1 and part_2 or part_1_2" + ) diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..5b3594c --- /dev/null +++ b/conftest.py @@ -0,0 +1,15 @@ +import re + +import pytest + +from aoc import input_file +from main import EXAMPLE_BASE + + +@pytest.fixture +def example_data(request): + match = re.match(r"^.*year_(\d{4})/day_(\d{2})\.py$", str(request.fspath)) + assert match is not None + year, day = list(map(int, match.groups())) + + return input_file.get(year, day, EXAMPLE_BASE) diff --git a/data/example/.keep b/data/example/.keep new file mode 100644 index 000000000..e69de29 diff --git a/data/input/.keep b/data/input/.keep new file mode 100644 index 000000000..e69de29 diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 000000000..9578127 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,3 @@ +pre-commit==4.5.0 +ruff==0.14.7 +pytest==9.0.1 diff --git a/main.py b/main.py new file mode 100644 index 000000000..7984e7a --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python + +import os +import pathlib +import sys + +import dotenv + +from aoc import cli, exceptions, input_file, run + +SOLUTION_BASE = pathlib.Path("./src/solution") +INPUT_BASE = pathlib.Path("./data/input") +EXAMPLE_BASE = pathlib.Path("./data/example") + + +def main() -> None: + args = cli.argument_parser().parse_args() + + session_token = os.getenv("AOC_SESSION_TOKEN") + + try: + match args.command: + case "solve": + input_data = input_file.get_or_download( + args.year, + args.day, + session_token, + EXAMPLE_BASE if args.example else INPUT_BASE, + ) + run.run_day(args.year, args.day, input_data, SOLUTION_BASE) + case "create": + input_file.create( + args.year, + args.day, + SOLUTION_BASE, + INPUT_BASE, + EXAMPLE_BASE, + ) + case "download": + input_file.create_input_file( + args.year, args.day, session_token, INPUT_BASE, args.force + ) + + except exceptions.AocError as e: + print(f"error: {e}", file=sys.stderr) + exit(1) + + +if __name__ == "__main__": + dotenv.load_dotenv() + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..0b34990 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.ruff] +extend-exclude = ['venv', 'env'] + +[tool.ruff.lint] +select = [ + 'F', # pyflakes + 'E', # pycodestyle + 'W', # pycodestyle + 'I', # isort + 'UP', # pyupgrade + 'B', # flake8-bugbear + 'C', # flake8-comprehensions + 'DTZ', # flake8-datetimez + 'DJ', # flake8-django + 'RUF', # ruff + 'N', # pep8-naming +] + +[tool.pytest.ini_options] +python_files = ['day_*.py'] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..d2c6cda --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-dotenv==1.2.1 +requests==2.32.5 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 000000000..e69de29