summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore234
-rw-r--r--.pre-commit-config.yaml15
-rw-r--r--aoc/__init__.py43
-rw-r--r--aoc/__main__.py66
-rw-r--r--aoc/datastructures.py98
-rw-r--r--aoc/decorators.py30
-rw-r--r--aoc/mixins.py77
-rw-r--r--aoc/template/__init__.py16
-rw-r--r--aoc/template/example.txt0
-rw-r--r--aoc/template/input.txt0
-rw-r--r--aoc/test_init.py8
-rw-r--r--aoc/tests/__init__.py0
-rw-r--r--aoc/tests/test_datastructures.py79
-rw-r--r--aoc/utils.py6
-rw-r--r--conftest.py36
-rw-r--r--requirements.txt3
16 files changed, 711 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f402d03
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,234 @@
1
2# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all
3# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all
4
5### PyCharm+all ###
6# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
7# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
8
9# User-specific stuff
10.idea/**/workspace.xml
11.idea/**/tasks.xml
12.idea/**/usage.statistics.xml
13.idea/**/dictionaries
14.idea/**/shelf
15
16# Generated files
17.idea/**/contentModel.xml
18
19# Sensitive or high-churn files
20.idea/**/dataSources/
21.idea/**/dataSources.ids
22.idea/**/dataSources.local.xml
23.idea/**/sqlDataSources.xml
24.idea/**/dynamic.xml
25.idea/**/uiDesigner.xml
26.idea/**/dbnavigator.xml
27
28# Gradle
29.idea/**/gradle.xml
30.idea/**/libraries
31
32# Gradle and Maven with auto-import
33# When using Gradle or Maven with auto-import, you should exclude module files,
34# since they will be recreated, and may cause churn. Uncomment if using
35# auto-import.
36# .idea/artifacts
37# .idea/compiler.xml
38# .idea/jarRepositories.xml
39# .idea/modules.xml
40# .idea/*.iml
41# .idea/modules
42# *.iml
43# *.ipr
44
45# CMake
46cmake-build-*/
47
48# Mongo Explorer plugin
49.idea/**/mongoSettings.xml
50
51# File-based project format
52*.iws
53
54# IntelliJ
55out/
56
57# mpeltonen/sbt-idea plugin
58.idea_modules/
59
60# JIRA plugin
61atlassian-ide-plugin.xml
62
63# Cursive Clojure plugin
64.idea/replstate.xml
65
66# Crashlytics plugin (for Android Studio and IntelliJ)
67com_crashlytics_export_strings.xml
68crashlytics.properties
69crashlytics-build.properties
70fabric.properties
71
72# Editor-based Rest Client
73.idea/httpRequests
74
75# Android studio 3.1+ serialized cache file
76.idea/caches/build_file_checksums.ser
77
78### PyCharm+all Patch ###
79# Ignores the whole .idea folder and all .iml files
80# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
81
82.idea/
83
84# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
85
86*.iml
87modules.xml
88.idea/misc.xml
89*.ipr
90
91# Sonarlint plugin
92.idea/sonarlint
93
94### Python ###
95# Byte-compiled / optimized / DLL files
96__pycache__/
97*.py[cod]
98*$py.class
99
100# C extensions
101*.so
102
103# Distribution / packaging
104.Python
105build/
106develop-eggs/
107dist/
108downloads/
109eggs/
110.eggs/
111lib/
112lib64/
113parts/
114sdist/
115var/
116wheels/
117pip-wheel-metadata/
118share/python-wheels/
119*.egg-info/
120.installed.cfg
121*.egg
122MANIFEST
123
124# PyInstaller
125# Usually these files are written by a python script from a template
126# before PyInstaller builds the exe, so as to inject date/other infos into it.
127*.manifest
128*.spec
129
130# Installer logs
131pip-log.txt
132pip-delete-this-directory.txt
133
134# Unit test / coverage reports
135htmlcov/
136.tox/
137.nox/
138.coverage
139.coverage.*
140.cache
141nosetests.xml
142coverage.xml
143*.cover
144*.py,cover
145.hypothesis/
146.pytest_cache/
147pytestdebug.log
148
149# Translations
150*.mo
151*.pot
152
153# Django stuff:
154*.log
155local_settings.py
156db.sqlite3
157db.sqlite3-journal
158
159# Flask stuff:
160instance/
161.webassets-cache
162
163# Scrapy stuff:
164.scrapy
165
166# Sphinx documentation
167docs/_build/
168doc/_build/
169
170# PyBuilder
171target/
172
173# Jupyter Notebook
174.ipynb_checkpoints
175
176# IPython
177profile_default/
178ipython_config.py
179
180# pyenv
181.python-version
182
183# pipenv
184# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
185# However, in case of collaboration, if having platform-specific dependencies or dependencies
186# having no cross-platform support, pipenv may install dependencies that don't work, or not
187# install all needed dependencies.
188#Pipfile.lock
189
190# PEP 582; used by e.g. github.com/David-OConnor/pyflow
191__pypackages__/
192
193# Celery stuff
194celerybeat-schedule
195celerybeat.pid
196
197# SageMath parsed files
198*.sage.py
199
200# Environments
201.env
202.venv
203env/
204venv/
205ENV/
206env.bak/
207venv.bak/
208pythonenv*
209
210# Spyder project settings
211.spyderproject
212.spyproject
213
214# Rope project settings
215.ropeproject
216
217# mkdocs documentation
218/site
219
220# mypy
221.mypy_cache/
222.dmypy.json
223dmypy.json
224
225# Pyre type checker
226.pyre/
227
228# pytype static type analyzer
229.pytype/
230
231# profiling data
232.prof
233
234# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..1232470
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,15 @@
1# See https://pre-commit.com for more information
2# See https://pre-commit.com/hooks.html for more hooks
3repos:
4 - repo: https://github.com/pre-commit/pre-commit-hooks
5 rev: v4.3.0
6 hooks:
7 - id: trailing-whitespace
8 - id: end-of-file-fixer
9 - id: check-merge-conflict
10 - id: debug-statements
11 - id: fix-encoding-pragma
12 - repo: https://github.com/psf/black
13 rev: 22.10.0
14 hooks:
15 - id: black
diff --git a/aoc/__init__.py b/aoc/__init__.py
new file mode 100644
index 0000000..7a089f6
--- /dev/null
+++ b/aoc/__init__.py
@@ -0,0 +1,43 @@
1# -*- coding: utf-8 -*-
2import os
3from abc import ABC
4from typing import Generator, Any, Iterator, Dict, TypeVar, Generic
5
6T = TypeVar("T")
7I = TypeVar("I")
8
9
10class BaseAssignment(Generic[T, I], ABC):
11 example_result: T = NotImplemented
12 example_kwargs: Dict = {}
13
14 def __init__(self, path):
15 self.path = path
16
17 def __str__(self):
18 return f"{self.__module__}.{self.__class__.__name__}"
19
20 def parse_item(self, item: str) -> I:
21 return item
22
23 @property
24 def part(self) -> int:
25 return 1 if self.__class__.__name__.endswith("One") else 2
26
27 def read_input(self, example=False) -> Iterator[I]:
28 file = f"{self.path}/input.txt"
29
30 if example or not os.path.isfile(file):
31 for file in [
32 f"{self.path}/example_part_{self.part}.txt",
33 f"{self.path}/example.txt",
34 ]:
35 if os.path.exists(file):
36 break
37
38 with open(file, "r") as input_file:
39 for line in input_file.readlines():
40 yield self.parse_item(line.strip("\n"))
41
42 def run(self, input: Iterator[I]) -> T:
43 raise NotImplementedError("Please implement run")
diff --git a/aoc/__main__.py b/aoc/__main__.py
new file mode 100644
index 0000000..9bbaed9
--- /dev/null
+++ b/aoc/__main__.py
@@ -0,0 +1,66 @@
1import enum
2import typing
3import importlib
4import os
5from pathlib import Path
6from shutil import copytree
7from time import perf_counter
8from typing import List, Callable
9import typer
10
11app = typer.Typer()
12
13
14class AssignmentPart(str, enum.Enum):
15 One = "1"
16 Two = "2"
17
18
19def day(assignment_part: str) -> str:
20 return assignment_part
21
22
23def kwargs(kwarg: str) -> List:
24 return kwarg.split("=")
25
26
27@app.command()
28def run(day: str,
29 part: str = typer.Option("One", '--part', help="Assignment part. Defaults to 'One'.", show_choices=True),
30 example: bool = typer.Option(False, '--example', help="Use an example input file"),
31 kwargs: List[str] = typer.Argument(None)
32 ):
33
34 assignment_day = importlib.import_module(day)
35
36 Assignment = getattr(assignment_day, f"Assignment{part}")
37 assignment = Assignment(
38 path=os.path.dirname(assignment_day.__file__), **dict(kwargs)
39 )
40
41 start = perf_counter()
42 typer.echo(
43 assignment.run(
44 input=assignment.read_input(example=example),
45 )
46 )
47 end = perf_counter()
48 delta = end - start
49 typer.secho(f'\n{(delta if delta > 1 else delta * 1000):.3f}{"s" if delta > 1 else "ms"}', fg="green")
50
51
52@app.command()
53def new(day: str):
54 self = importlib.import_module(".", package="aoc")
55 path = Path(self.__file__)
56 template = path.parent.joinpath("template")
57 target = path.parent.parent.joinpath(day)
58
59 try:
60 copytree(template, target)
61 except FileExistsError:
62 typer.secho(f"{day} already exists", fg="red")
63
64
65if __name__ == "__main__":
66 app()
diff --git a/aoc/datastructures.py b/aoc/datastructures.py
new file mode 100644
index 0000000..1f141e2
--- /dev/null
+++ b/aoc/datastructures.py
@@ -0,0 +1,98 @@
1# -*- coding: utf-8 -*-
2from collections import namedtuple
3from typing import Iterator
4
5
6class Coordinate(namedtuple("Coordinate", ["x", "y"])):
7 def __sub__(self, other: "Coordinate") -> "Coordinate":
8 return Coordinate(self.x - other.x, self.y - other.y)
9
10 def __add__(self, other: "Coordinate") -> "Coordinate":
11 return Coordinate(self.x + other.x, self.y + other.y)
12
13 def manhattan_distance(self, other: "Coordinate") -> int:
14 return abs(self.x - other.x) + abs(self.y - other.y)
15
16 @property
17 def polarity(self) -> "Coordinate":
18 try:
19 px = abs(self.x) / self.x
20 except ZeroDivisionError:
21 px = 0
22
23 try:
24 py = abs(self.y) / self.y
25 except ZeroDivisionError:
26 py = 0
27
28 return Coordinate(
29 px,
30 py,
31 )
32
33 def neighbours(self, no_diagonal: bool = False) -> Iterator["Coordinate"]:
34 if no_diagonal:
35 yield self + Coordinate(-1, 0)
36 yield self + Coordinate(1, 0)
37 yield self + Coordinate(0, -1)
38 yield self + Coordinate(0, 1)
39 else:
40 for dy in range(self.y - 1, self.y + 2):
41 for dx in range(self.x - 1, self.x + 2):
42 if dy == self.y and dx == self.x:
43 continue
44
45 yield Coordinate(dx, dy)
46
47
48class Coordinate3(namedtuple("Coordinate3", ["x", "y", "z"])):
49 def __sub__(self, other: "Coordinate3") -> "Coordinate3":
50 return Coordinate3(self.x - other.x, self.y - other.y, self.z - other.z)
51
52 def __add__(self, other: "Coordinate3") -> "Coordinate3":
53 return Coordinate3(self.x + other.x, self.y + other.y, self.z + other.z)
54
55 def manhattan_distance(self, other: "Coordinate3") -> int:
56 return abs(self.x - other.x) + abs(self.y - other.y) + abs(self.z - other.z)
57
58 @property
59 def polarity(self) -> "Coordinate3":
60 try:
61 px = abs(self.x) / self.x
62 except ZeroDivisionError:
63 px = 0
64
65 try:
66 py = abs(self.y) / self.y
67 except ZeroDivisionError:
68 py = 0
69
70 try:
71 pz = abs(self.z) / self.z
72 except ZeroDivisionError:
73 pz = 0
74
75 return Coordinate3(
76 px,
77 py,
78 pz,
79 )
80
81 def neighbours(self, no_diagonal: bool = False) -> Iterator["Coordinate3"]:
82 if no_diagonal:
83 yield self + Coordinate3(-1, 0, 0)
84 yield self + Coordinate3(1, 0, 0)
85 yield self + Coordinate3(0, -1, 0)
86 yield self + Coordinate3(0, 1, 0)
87 yield self + Coordinate3(0, 0, -1)
88 yield self + Coordinate3(0, 0, 1)
89 else:
90 for dz in range(self.z - 1, self.z + 2):
91 for dy in range(self.y - 1, self.y + 2):
92 for dx in range(self.x - 1, self.x + 2):
93 coordinate = Coordinate3(dx, dy, dz)
94
95 if dy == self.y and dx == self.x and dz == self.z:
96 continue
97
98 yield coordinate
diff --git a/aoc/decorators.py b/aoc/decorators.py
new file mode 100644
index 0000000..42603c2
--- /dev/null
+++ b/aoc/decorators.py
@@ -0,0 +1,30 @@
1# -*- coding: utf-8 -*-
2from functools import wraps
3from typing import Iterator, TypeVar, Callable, List
4
5T = TypeVar("T")
6I = TypeVar("I")
7
8AssignmentRun = Callable[[Iterator[I], ...], Iterator[T]]
9AssignmentRunList = Callable[[List[I], ...], Iterator[T]]
10
11
12def infinite_generator(func: AssignmentRun) -> AssignmentRun:
13 @wraps(func)
14 def wrapper(*args, **kwargs):
15 items = list(func(*args, **kwargs))
16
17 while True:
18 for item in items:
19 yield item
20
21 return wrapper
22
23
24def list_input(func: AssignmentRun) -> AssignmentRunList:
25 @wraps(func)
26 def wrapper(self, input: Iterator, *args, **kwargs) -> T:
27
28 return func(self, list(input), *args, **kwargs)
29
30 return wrapper
diff --git a/aoc/mixins.py b/aoc/mixins.py
new file mode 100644
index 0000000..5986b6e
--- /dev/null
+++ b/aoc/mixins.py
@@ -0,0 +1,77 @@
1# -*- coding: utf-8 -*-
2from math import floor, inf
3from typing import Tuple, TypeVar, Generic, Callable, Iterator, List, Dict
4
5Node = TypeVar("Node")
6
7
8class AStarMixin(Generic[Node]):
9 def _gen_path(self, current: Node, came_from: Dict[Node, Node]) -> List[Node]:
10 print("GENPATH")
11 path = [current]
12
13 while current in came_from:
14 current = came_from[current]
15 path = [current, *path]
16
17 return path
18
19 def a_star(
20 self,
21 start: Node,
22 end: Callable[[Node, Callable[[], List[Node]]], bool],
23 neighbours: Callable[[Node], Iterator[Node]],
24 distance: Callable[[Node, Node], int] = lambda a, b: 1,
25 heuristic: Callable[[Node], int] = lambda a: 0,
26 ) -> List[Node]:
27 open_nodes = {start}
28 came_from = {}
29
30 g_scores = {start: 0}
31
32 f_scores = {start: heuristic(start)}
33
34 while True:
35 try:
36 current = sorted(
37 [item for item in open_nodes],
38 key=lambda item: f_scores.get(item, inf),
39 )[0]
40 except IndexError:
41 raise RuntimeError("No path found")
42
43 if end(current, lambda: self._gen_path(current, came_from)):
44 return self._gen_path(current, came_from)
45
46 open_nodes.remove(current)
47
48 for neighbour in neighbours(current):
49 g_score = g_scores.get(current, inf) + distance(current, neighbour)
50
51 if g_score < g_scores.get(neighbour, inf):
52 came_from[neighbour] = current
53 g_scores[neighbour] = g_score
54 f_scores[neighbour] = g_score + heuristic(neighbour)
55
56 if neighbour not in open_nodes:
57 open_nodes.add(neighbour)
58
59
60class BreathFirstSearchMixin(Generic[Node]):
61 @staticmethod
62 def bfs(
63 start: Node, neighbours: Callable[[Node], Iterator[Node]]
64 ) -> Iterator[Node]:
65 queue = [start]
66 searched = set()
67
68 while len(queue):
69 item = queue.pop(0)
70
71 for neighbour in neighbours(item):
72 if neighbour not in searched and neighbour not in set(queue):
73 queue.append(neighbour)
74
75 searched.add(item)
76
77 yield item
diff --git a/aoc/template/__init__.py b/aoc/template/__init__.py
new file mode 100644
index 0000000..05b2b45
--- /dev/null
+++ b/aoc/template/__init__.py
@@ -0,0 +1,16 @@
1# -*- coding: utf-8 -*-
2from abc import ABC
3
4from aoc import BaseAssignment
5
6
7class Assignment(BaseAssignment, ABC):
8 pass
9
10
11class AssignmentOne(Assignment):
12 pass
13
14
15class AssignmentTwo(Assignment):
16 pass
diff --git a/aoc/template/example.txt b/aoc/template/example.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/aoc/template/example.txt
diff --git a/aoc/template/input.txt b/aoc/template/input.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/aoc/template/input.txt
diff --git a/aoc/test_init.py b/aoc/test_init.py
new file mode 100644
index 0000000..e52daf7
--- /dev/null
+++ b/aoc/test_init.py
@@ -0,0 +1,8 @@
1# -*- coding: utf-8 -*-
2def test_assignment_examples(assignment):
3 assert (
4 assignment.run(
5 input=assignment.read_input(example=True), **assignment.example_kwargs
6 )
7 == assignment.example_result
8 )
diff --git a/aoc/tests/__init__.py b/aoc/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/aoc/tests/__init__.py
diff --git a/aoc/tests/test_datastructures.py b/aoc/tests/test_datastructures.py
new file mode 100644
index 0000000..42cc44d
--- /dev/null
+++ b/aoc/tests/test_datastructures.py
@@ -0,0 +1,79 @@
1# -*- coding: utf-8 -*-
2from aoc.datastructures import Coordinate, Coordinate3
3
4
5class TestCoordinate:
6 def test_addition(self):
7 c1 = Coordinate(1, 1)
8 c2 = Coordinate(2, 2)
9 assert c1 + c2 == Coordinate(3, 3)
10
11 def test_subtraction(self):
12 c1 = Coordinate(2, 2)
13 c2 = Coordinate(1, 1)
14 assert c1 - c2 == Coordinate(1, 1)
15
16 def test_manhattan_distance(self):
17 c1 = Coordinate(1, 1)
18 c2 = Coordinate(3, 3)
19 assert c1.manhattan_distance(c2) == 4
20
21 def test_polarity(self):
22 c1 = Coordinate(2, -3)
23 assert c1.polarity == Coordinate(1, -1)
24
25 def test_neighbours(self):
26 c = Coordinate(0, 0)
27 neighbours = {Coordinate(-1, 0), Coordinate(1, 0), Coordinate(0, -1), Coordinate(0, 1),
28 Coordinate(-1, -1), Coordinate(-1, 1), Coordinate(1, -1), Coordinate(1, 1)}
29 assert set(c.neighbours()) == neighbours
30
31 def test_neighbours_no_diagonal(self):
32 c = Coordinate(0, 0)
33 neighbours_no_diagonal = {Coordinate(-1, 0), Coordinate(1, 0), Coordinate(0, -1), Coordinate(0, 1)}
34 assert set(c.neighbours(no_diagonal=True)) == neighbours_no_diagonal
35
36
37class TestCoordinate3:
38 def test_addition(self):
39 c1 = Coordinate3(1, 1, 1)
40 c2 = Coordinate3(2, 2, 2)
41 assert c1 + c2 == Coordinate3(3, 3, 3)
42
43 def test_subtraction(self):
44 c1 = Coordinate3(2, 2, 2)
45 c2 = Coordinate3(1, 1, 1)
46 assert c1 - c2 == Coordinate3(1, 1, 1)
47
48 def test_manhattan_distance(self):
49 c1 = Coordinate3(1, 1, 1)
50 c2 = Coordinate3(3, 3, 3)
51 assert c1.manhattan_distance(c2) == 6
52
53 def test_polarity(self):
54 c1 = Coordinate3(2, -3, 0)
55 assert c1.polarity == Coordinate3(1, -1, 0)
56
57 def test_neighbours(self):
58 c = Coordinate3(0, 0, 0)
59 neighbours = {
60 Coordinate3(-1, -1, -1), Coordinate3(-1, -1, 0), Coordinate3(-1, -1, 1),
61 Coordinate3(-1, 0, -1), Coordinate3(-1, 0, 0), Coordinate3(-1, 0, 1),
62 Coordinate3(-1, 1, -1), Coordinate3(-1, 1, 0), Coordinate3(-1, 1, 1),
63 Coordinate3(0, -1, -1), Coordinate3(0, -1, 0), Coordinate3(0, -1, 1),
64 Coordinate3(0, 0, -1), Coordinate3(0, 0, 1),
65 Coordinate3(0, 1, -1), Coordinate3(0, 1, 0), Coordinate3(0, 1, 1),
66 Coordinate3(1, -1, -1), Coordinate3(1, -1, 0), Coordinate3(1, -1, 1),
67 Coordinate3(1, 0, -1), Coordinate3(1, 0, 0), Coordinate3(1, 0, 1),
68 Coordinate3(1, 1, -1), Coordinate3(1, 1, 0), Coordinate3(1, 1, 1)
69 }
70 assert set(c.neighbours()) == neighbours
71
72 def test_neighbours_no_diagonal(self):
73 c = Coordinate3(0, 0, 0)
74 neighbours_no_diagonal = {
75 Coordinate3(-1, 0, 0), Coordinate3(1, 0, 0),
76 Coordinate3(0, -1, 0), Coordinate3(0, 1, 0),
77 Coordinate3(0, 0, -1), Coordinate3(0, 0, 1)
78 }
79 assert set(c.neighbours(no_diagonal=True)) == neighbours_no_diagonal
diff --git a/aoc/utils.py b/aoc/utils.py
new file mode 100644
index 0000000..a5de050
--- /dev/null
+++ b/aoc/utils.py
@@ -0,0 +1,6 @@
1# -*- coding: utf-8 -*-
2from typing import Callable
3
4
5def bold(item: str, condition: Callable[[], bool]):
6 return f"\033[36m{item}\033[0m" if condition() else item
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..1c0a8e4
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,36 @@
1# -*- coding: utf-8 -*-
2import importlib
3import os
4from pkgutil import walk_packages
5
6from _pytest.python import Metafunc
7
8from aoc.__main__ import day
9
10dir_path = os.path.dirname(os.path.realpath(__file__))
11
12
13def pytest_generate_tests(metafunc: Metafunc):
14 if "assignment" in metafunc.fixturenames:
15 packages = [
16 importlib.import_module(package.name)
17 for package in walk_packages([dir_path])
18 ]
19
20 assignments = [
21 (getattr(package, f"Assignment{day(part)}", None), package)
22 for package in packages
23 for part in ["1", "2"]
24 ]
25
26 metafunc.parametrize(
27 argnames=f"assignment",
28 argvalues=[
29 Assignment(path=package.__path__[0])
30 for (Assignment, package) in assignments
31 if Assignment is not None
32 and hasattr(package, "__path__")
33 and Assignment.example_result != NotImplemented
34 ],
35 ids=lambda assignment: str(assignment),
36 )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b8ff5dc
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
1pytest
2coverage
3typer[all]