From a355de5d8b7bd4fd2ab933c28afa4b50c7b82e81 Mon Sep 17 00:00:00 2001 From: Ruediger Ludwig Date: Wed, 30 Nov 2022 19:39:52 +0100 Subject: [PATCH] Initial --- .gitignore | 153 ++++++++++++ .vscode/launch.json | 32 +++ .vscode/settings.json | 22 ++ README.md | 5 + UNLICENSE | 24 ++ advent/__init__.py | 0 advent/__main__.py | 77 +++++++ advent/common/__init__.py | 0 advent/common/char_provider.py | 37 +++ advent/common/char_reader.py | 33 +++ advent/common/provider.py | 31 +++ advent/common/utils.py | 46 ++++ advent/days/__init__.py | 0 advent/days/day__/__init__.py | 0 advent/days/day__/data/input.txt | 0 advent/days/day__/solution.py | 13 ++ advent/days/day__/test_solution.py | 17 ++ advent/days/template.py | 23 ++ advent/parser/__init__.py | 0 advent/parser/parser.py | 359 +++++++++++++++++++++++++++++ advent/parser/result.py | 109 +++++++++ advent/parser/test_parser.py | 140 +++++++++++ setup.cfg | 9 + setup.py | 3 + 24 files changed, 1133 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 UNLICENSE create mode 100644 advent/__init__.py create mode 100644 advent/__main__.py create mode 100644 advent/common/__init__.py create mode 100644 advent/common/char_provider.py create mode 100644 advent/common/char_reader.py create mode 100644 advent/common/provider.py create mode 100644 advent/common/utils.py create mode 100644 advent/days/__init__.py create mode 100644 advent/days/day__/__init__.py create mode 100644 advent/days/day__/data/input.txt create mode 100644 advent/days/day__/solution.py create mode 100644 advent/days/day__/test_solution.py create mode 100644 advent/days/template.py create mode 100644 advent/parser/__init__.py create mode 100644 advent/parser/parser.py create mode 100644 advent/parser/result.py create mode 100644 advent/parser/test_parser.py create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b46c51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,153 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/python,vscode +# Edit at https://www.toptal.com/developers/gitignore?templates=python,vscode + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +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/ +pytestdebug.log + +# 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/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pythonenv* + +# 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/ + +# profiling data +.prof + +### vscode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/python,vscode diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c289692 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,32 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Advent", + "type": "python", + "request": "launch", + "console": "integratedTerminal", + "program": "advent" + }, + { + "name": "Python: Advent with Args", + "type": "python", + "request": "launch", + "console": "integratedTerminal", + "program": "advent", + "args": [ + "${input:dayToRun}" + ] + } + ], + "inputs": [ + { + "id": "dayToRun", + "type": "promptString", + "description": "Which day should be run?" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3e39b85 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "python.terminal.activateEnvInCurrentTerminal": true, + "python.analysis.diagnosticMode": "workspace", + "python.linting.flake8Enabled": true, + "python.analysis.typeCheckingMode": "strict", + "files.exclude": { + ".venv/": true, + "**/__pycache__/": true, + "**/.pytest_cache/": true, + "**/*.egg-info/": true + }, + "python.testing.pytestArgs": [], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.formatting.autopep8Args": [ + "--max-line-length", + "100", + "--aggressive", + "--ignore", + "W503" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..25cd2fc --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Advent of Code 2022 + +These are my solutions for [Advent of Code 2022](https://adventofcode.com/2022). Thanks to [Eric Wastl](http://was.tl) for the great puzzles and the great time I had solving them. + +All code is published under the [Unlicense](https://unlicense.org/) diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +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 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. + +For more information, please refer to diff --git a/advent/__init__.py b/advent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advent/__main__.py b/advent/__main__.py new file mode 100644 index 0000000..ffd647a --- /dev/null +++ b/advent/__main__.py @@ -0,0 +1,77 @@ +import sys +from importlib import import_module + +from advent.common import utils +from advent.days.template import Day, ResultType, is_day + + +def output(day: int, part: int, result: ResultType | None) -> None: + match result: + case int(value): + print('Day {0:02} Part {1}: {2}'.format(day, part, value)) + case str(value): + print('Day {0:02} Part {1}: {2}'.format(day, part, value)) + case list(value): + print('Day {0:02} Part {1}: {2}'.format(day, part, value[0])) + for line in value[1:]: + print(f' {line}') + case None: + print('Day {0:02} Part {1}: (No Result)'.format(day, part)) + case _: + print('Day {0:02} Part {1}: (Unknown result type)'.format(day, part)) + + +def get_day(day_num: int) -> Day: + day_module = import_module('advent.days.day{0:02}.solution'.format(day_num)) + if not is_day(day_module): + raise Exception(f'Not a valid day: {day_num}') + + return day_module + + +def run(day: Day, part: int) -> None: + data = utils.read_data(day.day_num, 'input.txt') + match part: + case 1: output(day.day_num, 1, day.part1(data)) + case 2: output(day.day_num, 2, day.part2(data)) + case _: raise Exception(f'Unknown part {part}') + + +def run_from_string(day_str: str) -> None: + match day_str.split('/'): + case [d]: + day = get_day(int(d)) + + run(day, 1) + run(day, 2) + + case [d, p]: + day = get_day(int(d)) + part = int(p) + + run(day, part) + + case _: + raise Exception(f'{day_str} is not a valid day description') + + +def main() -> None: + match sys.argv: + case [_]: + try: + for day_num in range(1, 25): + day = get_day(day_num) + run(day, 1) + run(day, 2) + except ModuleNotFoundError: + pass + + case [_, argument]: + run_from_string(argument) + + case _: + raise Exception(f'Usage: python {sys.argv[0]} [day[/part]]') + + +if __name__ == '__main__': + main() diff --git a/advent/common/__init__.py b/advent/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advent/common/char_provider.py b/advent/common/char_provider.py new file mode 100644 index 0000000..df03a2d --- /dev/null +++ b/advent/common/char_provider.py @@ -0,0 +1,37 @@ +from typing import Iterable, Iterator + +from .provider import EofException, Provider + + +class CharProvider(Provider[str]): + data: Iterator[str] + + def __init__(self, data: Iterator[str] | Iterable[str]) -> None: + if isinstance(data, Iterator): + self.data = data + else: + self.data = iter(data) + self.peeked: list[str] = [] + + def _ensure_next(self) -> str: + if not self.peeked: + try: + self.peeked = [next(self.data)] + except StopIteration: + raise EofException() from None + return self.peeked[0] + + def peek(self) -> str: + return self._ensure_next() + + def get(self) -> str: + result = self._ensure_next() + self.peeked = self.peeked[1:] + return result + + def finished(self) -> bool: + try: + self._ensure_next() + return False + except EofException: + return True diff --git a/advent/common/char_reader.py b/advent/common/char_reader.py new file mode 100644 index 0000000..3bfd03d --- /dev/null +++ b/advent/common/char_reader.py @@ -0,0 +1,33 @@ +from advent.common.provider import EofException +from .char_provider import CharProvider + + +class ReaderException(Exception): + pass + + +class CharReader: + @staticmethod + def read_word(provider: CharProvider, word: str) -> str: + result = '' + for expected_char in word: + try: + char = provider.get() + if char == expected_char: + result += char + else: + raise ReaderException(f'Expected {word} but received {result}{char}') + except EofException: + raise ReaderException(f'Expected {word} but received {result}[EOF]') + + return word + + @staticmethod + def read_unsigned_int(provider: CharProvider) -> int: + if not provider.peek().isdigit(): + raise ReaderException('Expected unsigned int') + + number = 0 + while not provider.finished() and provider.peek().isdigit(): + number = number * 10 + int(provider.get()) + return number diff --git a/advent/common/provider.py b/advent/common/provider.py new file mode 100644 index 0000000..6435f2b --- /dev/null +++ b/advent/common/provider.py @@ -0,0 +1,31 @@ +from abc import abstractmethod +from typing import Iterable, Iterator, Protocol, TypeVar + + +T = TypeVar('T', covariant=True) + + +class EofException(Exception): + pass + + +class Provider(Iterator[T], Iterable[T], Protocol[T]): + @abstractmethod + def peek(self) -> T: + ... + + @abstractmethod + def get(self) -> T: + ... + + @abstractmethod + def finished(self) -> bool: + ... + + def __next__(self) -> T: + if self.finished(): + raise StopIteration() + return self.get() + + def __iter__(self): + return self diff --git a/advent/common/utils.py b/advent/common/utils.py new file mode 100644 index 0000000..583961a --- /dev/null +++ b/advent/common/utils.py @@ -0,0 +1,46 @@ +from pathlib import Path, PurePath +from typing import Callable, Generator, Iterator, ParamSpec, TypeVar + +T = TypeVar('T') + + +def read_data(day: int, file_name: str) -> Iterator[str]: + ''' + Returns an iterator over the content of the mentioned file + All lines are striped of an eventual trailing '\n' their + ''' + with open( + Path.cwd() + / PurePath('advent/days/day{0:02}/data'.format(day)) + / PurePath(file_name), + 'rt', + ) as file: + while True: + line = file.readline() + if line: + yield line if line[-1] != '\n' else line[:-1] + else: + return + + +def split_set(full_set: set[T], predicate: Callable[[T], bool]) -> tuple[set[T], set[T]]: + ''' Splits a set in two sorted by the predicate ''' + true_set: set[T] = set() + false_set: set[T] = set() + for item in full_set: + (true_set if predicate(item) else false_set).add(item) + return true_set, false_set + + +P = ParamSpec('P') +Y = TypeVar('Y') +S = TypeVar('S') +R = TypeVar('R') + + +def coroutine(func: Callable[P, Generator[Y, S, R]]) -> Callable[P, Generator[Y, S, R]]: + def start(*args: P.args, **kwargs: P.kwargs) -> Generator[Y, S, R]: + cr = func(*args, **kwargs) + next(cr) + return cr + return start diff --git a/advent/days/__init__.py b/advent/days/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advent/days/day__/__init__.py b/advent/days/day__/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advent/days/day__/data/input.txt b/advent/days/day__/data/input.txt new file mode 100644 index 0000000..e69de29 diff --git a/advent/days/day__/solution.py b/advent/days/day__/solution.py new file mode 100644 index 0000000..52dfac1 --- /dev/null +++ b/advent/days/day__/solution.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Iterator + +day_num = 0 + + +def part1(lines: Iterator[str]) -> None: + return None + + +def part2(lines: Iterator[str]) -> None: + return None diff --git a/advent/days/day__/test_solution.py b/advent/days/day__/test_solution.py new file mode 100644 index 0000000..552dd3a --- /dev/null +++ b/advent/days/day__/test_solution.py @@ -0,0 +1,17 @@ +from advent.common import utils + +from .solution import day_num, part1, part2 + + +def test_part1(): + data = utils.read_data(day_num, '') + expected = None + result = part1(data) + assert result == expected + + +def test_part2(): + data = utils.read_data(day_num, '') + expected = None + result = part2(data) + assert result == expected diff --git a/advent/days/template.py b/advent/days/template.py new file mode 100644 index 0000000..a8c85b4 --- /dev/null +++ b/advent/days/template.py @@ -0,0 +1,23 @@ +import typing + +ResultType = int | str | list[str] + + +class Day(typing.Protocol): + day_num: int + + @staticmethod + def part1(lines: typing.Iterator[str]) -> ResultType | None: + ... + + @staticmethod + def part2(lines: typing.Iterator[str]) -> ResultType | None: + ... + + +def is_day(object: typing.Any) -> typing.TypeGuard[Day]: + try: + return (isinstance(object.day_num, int) + and callable(object.part1) and callable(object.part2)) + except AttributeError: + return False diff --git a/advent/parser/__init__.py b/advent/parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advent/parser/parser.py b/advent/parser/parser.py new file mode 100644 index 0000000..9772844 --- /dev/null +++ b/advent/parser/parser.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import reduce +from itertools import chain +from typing import Any, Callable, Generic, Self, TypeVar, overload +import unicodedata + +from .result import Result + +T = TypeVar('T') +T1 = TypeVar('T1') +T2 = TypeVar('T2') +T3 = TypeVar('T3') +T4 = TypeVar('T4') +T5 = TypeVar('T5') +TR = TypeVar('TR') + + +@dataclass(slots=True, frozen=True) +class ParserInput: + input: str + start: int + + def step(self, count: int = 1) -> tuple[Self, str]: + assert count > 0 + if self.start + count > len(self.input): + raise Exception("Not enough chars left in string") + return ParserInput(self.input, self.start + + count), self.input[self.start:self.start + count] + + def is_eof(self) -> bool: + return self.start >= len(self.input) + + def __repr__(self) -> str: + if self.start == 0: + return f'->[{self.input}]' + if self.start >= len(self.input): + return f'{self.input}' + if self.start < 3: + return f'{self.input[0:self.start-1]}->[{self.input[self.start:]}]' + return f'{self.input[self.start-3:self.start-1]}->[{self.input[self.start:]}]' + + +ParserResult = Result[tuple[ParserInput, T]] +ParserFunc = Callable[[ParserInput], ParserResult[T]] + + +class P(Generic[T]): + def __init__(self, func: ParserFunc[T]): + self.func = func + + def parse(self, s: str, i: int = 0) -> Result[T]: + result = self.func(ParserInput(s, i)) + return result.fmap(lambda pr: pr[1]) + + @staticmethod + def pure(value: T) -> P[T]: + return P(lambda parserPos: Result.of((parserPos, value))) + + @staticmethod + def fail(text: str) -> P[Any]: + return P(lambda pp: Result.fail(f'@: {pp}\ntext: {text}')) + + @staticmethod + def _fix(p1: Callable[[P[Any]], P[T]]) -> P[T]: + """ Not really nice helper function, but it works""" + return [p._forward(q.func) for p in [P(None)] for q in [p1(p)]][0] # type: ignore + + def _forward(self, func: ParserFunc[T]) -> Self: + self.func = func + return self + + def bind(self, bind_func: Callable[[T], P[TR]]) -> P[TR]: + def inner(x: tuple[ParserInput, T]): + parserPos, v = x + return bind_func(v).func(parserPos) + + return P(lambda parserPos: self.func(parserPos).bind(inner)) + + def fmap(self, map_func: Callable[[T], TR]) -> P[TR]: + def inner(x: tuple[ParserInput, T]): + parserPos, v = x + return (parserPos, map_func(v)) + return P(lambda parserPos: self.func(parserPos).fmap(inner)) + + def safe_fmap(self, map_func: Callable[[T], TR]) -> P[TR]: + def inner(value: T) -> P[TR]: + try: + return P.pure(map_func(value)) + except Exception as e: + return P.fail(f'Failed with {e}') + return self.bind(inner) + + def replace(self, value: TR) -> P[TR]: + return self.fmap(lambda _: value) + + def unit(self) -> P[tuple[()]]: + return self.fmap(lambda _: ()) + + def apply(self, p2: P[Callable[[T], TR]]) -> P[TR]: + return self.bind(lambda x: p2.bind(lambda y: P.pure(y(x)))) + + def between(self, pre: P[Any], post: P[Any]) -> P[T]: + return P.map3(pre, self, post, lambda _1, v, _2: v) + + def surround(self, other: P[Any]) -> P[T]: + return P.map3(other, self, other, lambda _1, v, _2: v) + + def some(self) -> P[list[T]]: + return P._fix(lambda p: self.bind( + lambda x: P.either(p, P.pure([])).fmap(lambda ys: [x] + ys))) + + def many(self) -> P[list[T]]: + return P.either(self.some(), P.pure([])) + + def satisfies(self, pred: Callable[[T], bool], failtext: str) -> P[T]: + return self.bind(lambda v: P.pure(v) if pred( + v) else P.fail(f'Does not satisfy: {failtext}')) + + def optional(self) -> P[T | None]: + return P.either(self, P.pure(None)) + + def sep_by(self, sep: P[Any]) -> P[list[T]]: + return P.map2(self, P.snd(sep, self).many(), lambda f, r: [f] + r) + + @staticmethod + def snd(p1: P[Any], p2: P[T2]) -> P[T2]: + return p1.bind(lambda _: p2) + + @staticmethod + def fst(p1: P[T1], p2: P[Any]) -> P[T1]: + return P.map2(p1, p2, lambda v1, _: v1) + + @staticmethod + def no_match(p: P[Any], failtext: str) -> P[tuple[()]]: + def inner(pp: ParserInput) -> ParserResult[tuple[()]]: + result = p.func(pp) + if result.is_fail(): + return Result.of((pp, ())) + else: + return Result.fail(failtext) + return P(inner) + + @ staticmethod + def map2(p1: P[T1], p2: P[T2], func: Callable[[T1, T2], TR]) -> P[TR]: + return p1.bind(lambda v1: p2.fmap(lambda v2: func(v1, v2))) + + @ staticmethod + def map3(p1: P[T1], p2: P[T2], p3: P[T3], func: Callable[[T1, T2, T3], TR]) -> P[TR]: + return p1.bind( + lambda v1: p2.bind( + lambda v2: p3.fmap( + lambda v3: func(v1, v2, v3)))) + + @ staticmethod + def map4(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], + func: Callable[[T1, T2, T3, T4], TR]) -> P[TR]: + return p1.bind( + lambda v1: p2.bind( + lambda v2: p3.bind( + lambda v3: p4.fmap( + lambda v4: func(v1, v2, v3, v4))))) + + @ staticmethod + def map5(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], p5: P[T5], + func: Callable[[T1, T2, T3, T4, T5], TR]) -> P[TR]: + return p1.bind( + lambda v1: p2.bind( + lambda v2: p3.bind( + lambda v3: p4.bind( + lambda v4: p5.fmap( + lambda v5: func(v1, v2, v3, v4, v5)))))) + + @ staticmethod + @ overload + def seq(p1: P[T1], p2: P[T2], /) -> P[tuple[T1, T2]]: + ... + + @ staticmethod + @ overload + def seq(p1: P[T1], p2: P[T2], p3: P[T3], /) -> P[tuple[T1, T2, T3]]: + ... + + @ staticmethod + @ overload + def seq(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], /) -> P[tuple[T1, T2, T3, T4]]: + ... + + @ staticmethod + @ overload + def seq(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], + p5: P[T5], /) -> P[tuple[T1, T2, T3, T4, T5]]: + ... + + @ staticmethod + def seq(*ps: P[Any]) -> P[tuple[Any, ...]]: + return reduce(lambda p, x: x.bind( + lambda a: p.fmap(lambda b: chain([a], b))), + list(ps)[::-1], P.pure(iter([]))).fmap(tuple) + + @ staticmethod + @ overload + def sep_seq(p1: P[T1], p2: P[T2], /, *, sep: P[Any]) -> P[tuple[T1, T2]]: + ... + + @ staticmethod + @ overload + def sep_seq(p1: P[T1], p2: P[T2], p3: P[T3], /, *, sep: P[Any]) -> P[tuple[T1, T2, T3]]: + ... + + @ staticmethod + @ overload + def sep_seq(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], /, + *, sep: P[Any]) -> P[tuple[T1, T2, T3, T4]]: + ... + + @ staticmethod + @ overload + def sep_seq(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], + p5: P[T5], /, *, sep: P[Any]) -> P[tuple[T1, T2, T3, T4, T5]]: + ... + + @ staticmethod + def sep_seq(*ps: P[Any], sep: P[Any]) -> P[tuple[Any, ...]]: + first, *rest = list(ps) + return P.map2(first, + reduce(lambda p, x: P.snd(sep, x.bind( + lambda a: p.fmap(lambda b: chain([a], b)))), + rest[::-1], P.pure(iter([]))), + lambda f, r: (f,) + tuple(r)) + + @ staticmethod + def either(p1: P[T1], p2: P[T2], /) -> P[T1 | T2]: + def inner(parserPos: ParserInput): + result = p1.func(parserPos) + return result if result.is_ok() else p2.func(parserPos) + + return P(inner) + + @ staticmethod + @ overload + def choice(p1: P[T1], p2: P[T2], p3: P[T3], /) -> P[T1 | T2 | T3]: + ... + + @ staticmethod + @ overload + def choice(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], /) -> P[T1 | T2 | T3 | T4]: + ... + + @ staticmethod + @ overload + def choice(p1: P[T1], p2: P[T2], p3: P[T3], p4: P[T4], + p5: P[T5], /) -> P[T1 | T2 | T3 | T4 | T5]: + ... + + @ staticmethod + def choice(*ps: P[Any]) -> P[Any]: + return reduce(P.either, ps, P.fail('No Choice matched')) + + @ staticmethod + def choice_same(*ps: P[T]) -> P[T]: + return P.choice(*ps) + + @ staticmethod + def any_char() -> P[str]: + return P(lambda pp: Result.of(pp.step()) if not pp.is_eof() + else Result.fail('At EOF')) + + @ staticmethod + def eof() -> P[tuple[()]]: + return P.no_match(P.any_char(), 'Not at eof') + + @ staticmethod + def is_char(cmp: str) -> P[str]: + return P.any_char().satisfies(lambda c: c == cmp, f'match {cmp}') + + @staticmethod + def is_not_char(s: str) -> P[tuple[()]]: + return P.no_match(P.is_char(s), f'Did match {s}') + + @ staticmethod + def char_by_func(cmp: Callable[[str], bool], failtext: str) -> P[str]: + return P.any_char().satisfies(cmp, failtext) + + @ staticmethod + def string(s: str) -> P[str]: + return P.seq(*map(P.is_char, s)).replace(s) + + @ staticmethod + def one_of(s: str) -> P[str]: + return P.char_by_func(lambda c: c in s, f'one of {s}') + + @ staticmethod + def any_decimal() -> P[str]: + return P.char_by_func(lambda c: c.isdecimal(), 'decimal') + + @ staticmethod + def is_decimal(num: int) -> P[str]: + return P.any_decimal().bind( + lambda c: P.pure(c) if unicodedata.decimal(c) == num else P.fail(f'Not {num}')) + + @ staticmethod + def is_not_decimal(num: int) -> P[str]: + return P.any_decimal().bind( + lambda c: P.pure(c) if unicodedata.decimal(c) != num else P.fail(f'Is {num}')) + + @ staticmethod + def lower() -> P[str]: + return P.char_by_func(lambda c: c.islower(), 'lower') + + @ staticmethod + def upper() -> P[str]: + return P.char_by_func(lambda c: c.isupper(), 'upper') + + @ staticmethod + def joined(p1: P[str]) -> P[str]: + return p1.many().fmap(lambda cs: ''.join(cs)) + + @ staticmethod + def space() -> P[str]: + return P.char_by_func(lambda c: c.isspace(), 'space') + + @ staticmethod + def unsigned() -> P[int]: + return P.either(P.fst(P.is_decimal(0), P.no_match(P.any_decimal(), 'starting Zero')), + P.map2(P.is_not_decimal(0), P.any_decimal().many(), + lambda f, s: f + ''.join(s)) + ).fmap(int) + + @ staticmethod + def signed() -> P[int]: + return P.map2(P.one_of('+-').optional(), P.unsigned(), + lambda sign, num: num if sign != '-' else -num) + + def in_parens(self) -> P[T]: + return self.between(P.is_char('('), P.is_char(')')) + + def in_angles(self) -> P[T]: + return self.between(P.is_char('<'), P.is_char('>')) + + def in_brackets(self) -> P[T]: + return self.between(P.is_char('['), P.is_char(']')) + + def in_curleys(self) -> P[T]: + return self.between(P.is_char('{'), P.is_char('}')) + + def trim_left(self) -> P[T]: + return P.snd(WHITE_SPACE, self) + + def trim_right(self) -> P[T]: + return P.fst(self, WHITE_SPACE) + + def trim(self) -> P[T]: + return self.surround(WHITE_SPACE) + + +WHITE_SPACE: P[tuple[()]] = P.space().many().unit() +SEP_SPACE: P[tuple[()]] = P.space().some().unit() diff --git a/advent/parser/result.py b/advent/parser/result.py new file mode 100644 index 0000000..b7ba9f8 --- /dev/null +++ b/advent/parser/result.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable, Generic, Never, TypeVar + +S = TypeVar("S", covariant=True) +S1 = TypeVar("S1") +S2 = TypeVar("S2") +S3 = TypeVar("S3") + + +class Result(ABC, Generic[S]): + @staticmethod + def of(value: S1) -> Result[S1]: + return Success(value) + + @staticmethod + def fail(failure: str) -> Result[Any]: + return Failure(failure) + + @abstractmethod + def is_ok(self) -> bool: + pass + + @abstractmethod + def is_fail(self) -> bool: + pass + + @abstractmethod + def fmap(self, func: Callable[[S], S2]) -> Result[S2]: + pass + + @abstractmethod + def bind(self, func: Callable[[S], Result[S2]]) -> Result[S2]: + pass + + @abstractmethod + def get(self) -> S: + pass + + @abstractmethod + def get_or(self, default: S1) -> S | S1: + pass + + @abstractmethod + def get_or_else(self, default: Callable[[], S]) -> S: + pass + + @abstractmethod + def get_error(self) -> str: + pass + + +class Success(Result[S]): + def __init__(self, value: S): + self.value = value + + def is_ok(self) -> bool: + return True + + def is_fail(self) -> bool: + return False + + def fmap(self, func: Callable[[S], S2]) -> Result[S2]: + return Result.of(func(self.value)) # type: ignore + + def bind(self, func: Callable[[S], Result[S2]]) -> Result[S2]: + return func(self.value) + + def get(self) -> S: + return self.value + + def get_or(self, default: S1) -> S | S1: + return self.value + + def get_or_else(self, default: Callable[[], S]) -> S: + return self.value + + def get_error(self) -> Never: + raise Exception("No Error in Success Value") + + +class Failure(Result[Any]): + def __init__(self, value: str): + self.value = value + + def is_ok(self) -> bool: + return False + + def is_fail(self) -> bool: + return True + + def fmap(self, func: Callable[[Any], S2]) -> Result[S2]: + return self + + def bind(self, func: Callable[[Any], Result[S2]]) -> Result[S2]: + return self + + def get(self) -> Never: + raise Exception("No value in Fail") + + def get_or(self, default: S1) -> S1: + return default + + def get_or_else(self, default: Callable[[], S]) -> S: + return default() + + def get_error(self) -> str: + return self.value diff --git a/advent/parser/test_parser.py b/advent/parser/test_parser.py new file mode 100644 index 0000000..844c99d --- /dev/null +++ b/advent/parser/test_parser.py @@ -0,0 +1,140 @@ +from .parser import P +import pytest + + +def test_one_letter(): + parser = P.is_char('!') + input = '!' + expected = '!' + result = parser.parse(input).get() + assert result == expected + + +def test_one_letter_longer(): + parser = P.is_char('!') + input = '!!' + expected = '!' + result = parser.parse(input).get() + assert result == expected + + +def test_one_string(): + parser = P.string('123') + input = '12345' + expected = '123' + result = parser.parse(input).get() + assert result == expected + + +def test_eof(): + parser = P.eof() + input = '' + result = parser.parse(input).get() + assert result == () + + input = '!' + with pytest.raises(Exception): + parser.parse(input).get() + + +def test_integer(): + parser = P.signed() + input = '123456' + expected = 123456 + result = parser.parse(input).get() + assert result == expected + + +def test_signed_integer(): + parser = P.signed() + input = '-123456' + expected = -123456 + result = parser.parse(input).get() + assert result == expected + + +def test_starting_zero(): + parser = P.unsigned() + input = '0a' + expected = 0 + result = parser.parse(input).get() + assert result == expected + + input2 = '01' + result2 = parser.parse(input2) + assert result2.is_fail() + + +def test_between(): + parser = P.signed().between(P.is_char('<'), P.is_char('>')) + input = '<-123456>' + expected = -123456 + result = parser.parse(input).get() + assert result == expected + + +def test_sep_by(): + parser = P.signed().sep_by(P.is_char(',')) + input = '1,1,2,3,5,8,13' + expected = [1, 1, 2, 3, 5, 8, 13] + result = parser.parse(input).get() + assert result == expected + + +def test_trim(): + parser = P.signed().trim() + input = '1' + expected = 1 + result = parser.parse(input).get() + assert result == expected + + +def test_sep_by_trim(): + parser = P.signed().sep_by(P.is_char(',').trim()).trim() + input = ' 1 , 1 , 2 , 3 , 5 , 8 , 13!' + expected = [1, 1, 2, 3, 5, 8, 13] + result = parser.parse(input).get() + assert result == expected + + +def test_choice2(): + parser = P.choice(P.is_char('a'), P.unsigned(), P.string('hallo')) + input = '1' + expected = 1 + result = parser.parse(input).get() + assert result == expected + + input = 'hallo' + expected = 'hallo' + result = parser.parse(input).get() + assert result == expected + + +def test_seq(): + input = '1234' + parser = P.seq(P.any_char(), P.any_char(), P.any_char(), P.any_char()) + expected = ('1', '2', '3', '4') + result = parser.parse(input).get() + assert result == expected + + +def test_seq_seq(): + input = '1,2,3,4' + digit = P.char_by_func(lambda c: c.isdigit(), "") + parser = P.sep_seq(digit, digit, digit, digit, sep=P.is_char(',')) + + expected = ('1', '2', '3', '4') + result = parser.parse(input).get() + assert result == expected + + +def test_not(): + input = 'a' + parser = P.snd(P.no_match(P.is_char('!'), 'found !'), P.is_char('a')) + expected = 'a' + result = parser.parse(input).get() + assert result == expected + + input2 = '!' + result2 = parser.parse(input2) + assert result2.is_fail() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d3be57b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[flake8] +# ignore = E203, E266, E501, W503 +ignore=W503 +max-line-length = 100 +max-complexity = 18 +select = B,C,E,F,W,T4 + +[tool:pytest] +norecursedirs = .* day__ \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..74b8843 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import find_packages, setup + +setup(name='advent', packages=find_packages())