From f3b99015ef7e97fd3865fa6385de9c03cee80a44 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 17 Jun 2025 20:50:32 +0200 Subject: [PATCH 1/6] feat: Implement setting birth and survival condition from cli also merged from-file command with main and implemented proper typing --- game/main.py | 39 +++++++++++++++++++++++---------------- game/tests/cli/cli.py | 8 ++++++++ game/utils/cli.py | 5 +++++ uv.lock | 2 +- 4 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 game/utils/cli.py diff --git a/game/main.py b/game/main.py index 8059ed1..6c70b5b 100644 --- a/game/main.py +++ b/game/main.py @@ -1,5 +1,8 @@ +from typing import Annotated + import typer +from game.utils.cli import parse_multiple_value_argument_from_str from game.utils.map import Map from game.utils.tui import run @@ -8,27 +11,31 @@ @app.callback(invoke_without_command=True) def main( - ctx: typer.Context, - map_grid: str = typer.Option( - None, - "--map-grid", - "-m", + map_grid: Annotated[str, typer.Option( help="Load a map from a string representation", - ), + )] = None, + file_path: Annotated[str, typer.Option( + help="Load a map from a file", + )] = None, + survival_condition: Annotated[str, typer.Option( + help="Survival condition for the game, e.g., [2, 3]", + )] = None, + birth_condition: Annotated[str, typer.Option( + help="Birth condition for the game, e.g., [3]", + )] = None, ): - if ctx.invoked_subcommand is not None: - return - game_map = Map() - if map_grid is not None: - game_map.load_from_str(map_grid) - run(game_map) + if survival_condition is not None: + game_map.survival_condition = parse_multiple_value_argument_from_str(survival_condition) + if birth_condition is not None: + game_map.birth_condition = birth_condition + game_map.birth_condition = parse_multiple_value_argument_from_str(birth_condition) -@app.command() -def from_file(file_path: str): - game_map = Map() - game_map.load_from_file(file_path) + if file_path is not None: + game_map.load_from_file(file_path) + elif map_grid is not None: + game_map.load_from_str(map_grid) run(game_map) diff --git a/game/tests/cli/cli.py b/game/tests/cli/cli.py index a60ab12..ca532d2 100644 --- a/game/tests/cli/cli.py +++ b/game/tests/cli/cli.py @@ -1,5 +1,6 @@ from game.utils.map import Map from game.utils.tui import prepare_display +from game.utils.cli import parse_multiple_value_argument_from_str def test_from_arg(): @@ -47,3 +48,10 @@ def test_tui(): expected_display = open("game/tests/cli/expected_display.txt").read() assert display == expected_display + + +def test_parsing_multiple_value_argument(): + assert parse_multiple_value_argument_from_str("2, 3") == [2, 3] + assert parse_multiple_value_argument_from_str("3") == [3] + assert parse_multiple_value_argument_from_str("") == [] + diff --git a/game/utils/cli.py b/game/utils/cli.py new file mode 100644 index 0000000..9c75af6 --- /dev/null +++ b/game/utils/cli.py @@ -0,0 +1,5 @@ +def parse_multiple_value_argument_from_str(argument_str: str) -> list[int]: + if not argument_str: + return [] + + return [int(x) for x in argument_str.split(",") if x.strip().isdigit()] diff --git a/uv.lock b/uv.lock index 1dd0ced..79c151f 100644 --- a/uv.lock +++ b/uv.lock @@ -58,7 +58,7 @@ wheels = [ [[package]] name = "gameoflifepython" -version = "0.1.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "pre-commit" }, From 4b01cf67c1886d4167b26dfa63ec941a58703f42 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 17 Jun 2025 21:28:24 +0200 Subject: [PATCH 2/6] refactor: Proper typing in Map class attributes --- game/utils/map.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/game/utils/map.py b/game/utils/map.py index 2c8f862..ecc77ba 100644 --- a/game/utils/map.py +++ b/game/utils/map.py @@ -3,9 +3,9 @@ class AbstractMap: Abstract class for the map. """ - population = int - generation = int - map = list[list[bool]] + population: int + generation: int + map: list[list[bool]] def __init__(self, game_map: list[list[bool]] = None): if game_map is None: @@ -65,10 +65,10 @@ def next_generation(self): class Map(AbstractMap): - birth_condition = (list[int],) - survival_condition = (list[int],) - number_of_rows = int - number_of_columns = int + birth_condition: list[int] + survival_condition: list[int] + number_of_rows: int + number_of_columns: int def __init__(self, game_map: list[list[bool]] = None): super().__init__(game_map) From 7b7d203b0616bb2f93dff3982c5e002988c71a7a Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 17 Jun 2025 21:29:47 +0200 Subject: [PATCH 3/6] feat: Handle different neighbouring conditions --- game/main.py | 6 +++++- game/tests/map.py | 7 +++++++ game/utils/map.py | 25 ++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/game/main.py b/game/main.py index 6c70b5b..07a0e08 100644 --- a/game/main.py +++ b/game/main.py @@ -23,14 +23,18 @@ def main( birth_condition: Annotated[str, typer.Option( help="Birth condition for the game, e.g., [3]", )] = None, + neighbouring_condition: Annotated[int, typer.Option( + help="Number of neighbouring cells to consider for the game", + )] = 1 ): game_map = Map() if survival_condition is not None: game_map.survival_condition = parse_multiple_value_argument_from_str(survival_condition) if birth_condition is not None: - game_map.birth_condition = birth_condition game_map.birth_condition = parse_multiple_value_argument_from_str(birth_condition) + if neighbouring_condition is not None: + game_map.neighbouring_condition = neighbouring_condition if file_path is not None: game_map.load_from_file(file_path) diff --git a/game/tests/map.py b/game/tests/map.py index f86234d..286cd15 100644 --- a/game/tests/map.py +++ b/game/tests/map.py @@ -10,6 +10,13 @@ def test_neighbour_count(): assert neighbours == excepted_neighbours +def test_neighbour_count_condition_2(): + excepted_neighbours = 3 + game_map = Map() + + neighbours = game_map.count_neighbours(1, 2) + assert neighbours == excepted_neighbours + def test_neighbour_count_fail(): excepted_neighbours = 1 game_map = Map() diff --git a/game/utils/map.py b/game/utils/map.py index ecc77ba..9f6c157 100644 --- a/game/utils/map.py +++ b/game/utils/map.py @@ -49,7 +49,7 @@ def count_neighbours(self, x, y) -> int: try: self.map[y][x] except IndexError: - raise IndexError("Invalid coordinates.") from IndexError + raise IndexError("Invalid coordinates.") from None for i in range(y - 1, y + 2): if 0 <= i < len(self.map): @@ -67,6 +67,7 @@ def next_generation(self): class Map(AbstractMap): birth_condition: list[int] survival_condition: list[int] + neighbouring_condition: int number_of_rows: int number_of_columns: int @@ -74,6 +75,7 @@ def __init__(self, game_map: list[list[bool]] = None): super().__init__(game_map) self.birth_condition = [3] self.survival_condition = [2, 3] + self.neighbouring_condition = 1 self.number_of_rows = len(self.map) self.number_of_columns = len(self.map[0]) @@ -136,3 +138,24 @@ def next_generation(self): self.map = new_map self.generation += 1 self.population = population + + def count_neighbours(self, x, y) -> int | None: + neighbours = 0 + + if len(self.map) == 1: + if len(self.map[y]) == 1: + return neighbours + + try: + self.map[y][x] + except IndexError: + raise IndexError("Invalid coordinates.") from None + + for neighbouring_y in range(y - self.neighbouring_condition, y + (self.neighbouring_condition + 1)): + if 0 <= neighbouring_y < len(self.map): + for neighbouring_x in range(x - self.neighbouring_condition, x + (self.neighbouring_condition + 1)): + if 0 <= neighbouring_x < len(self.map[neighbouring_y]) and (neighbouring_y != y or neighbouring_x != x): + if self.map[neighbouring_y][neighbouring_x]: + neighbours += 1 + + return neighbours From 3502b26bda03f3205e2ad460d4d92ba211ff962e Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Tue, 17 Jun 2025 21:32:00 +0200 Subject: [PATCH 4/6] chore: Reformat code --- game/main.py | 45 ++++++++++++++++++++++++++++--------------- game/tests/cli/cli.py | 3 +-- game/tests/map.py | 1 + game/utils/map.py | 4 +++- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/game/main.py b/game/main.py index 07a0e08..114986d 100644 --- a/game/main.py +++ b/game/main.py @@ -11,21 +11,36 @@ @app.callback(invoke_without_command=True) def main( - map_grid: Annotated[str, typer.Option( - help="Load a map from a string representation", - )] = None, - file_path: Annotated[str, typer.Option( - help="Load a map from a file", - )] = None, - survival_condition: Annotated[str, typer.Option( - help="Survival condition for the game, e.g., [2, 3]", - )] = None, - birth_condition: Annotated[str, typer.Option( - help="Birth condition for the game, e.g., [3]", - )] = None, - neighbouring_condition: Annotated[int, typer.Option( - help="Number of neighbouring cells to consider for the game", - )] = 1 + map_grid: Annotated[ + str, + typer.Option( + help="Load a map from a string representation", + ), + ] = None, + file_path: Annotated[ + str, + typer.Option( + help="Load a map from a file", + ), + ] = None, + survival_condition: Annotated[ + str, + typer.Option( + help="Survival condition for the game, e.g., [2, 3]", + ), + ] = None, + birth_condition: Annotated[ + str, + typer.Option( + help="Birth condition for the game, e.g., [3]", + ), + ] = None, + neighbouring_condition: Annotated[ + int, + typer.Option( + help="Number of neighbouring cells to consider for the game", + ), + ] = 1, ): game_map = Map() diff --git a/game/tests/cli/cli.py b/game/tests/cli/cli.py index ca532d2..54f5f2c 100644 --- a/game/tests/cli/cli.py +++ b/game/tests/cli/cli.py @@ -1,6 +1,6 @@ +from game.utils.cli import parse_multiple_value_argument_from_str from game.utils.map import Map from game.utils.tui import prepare_display -from game.utils.cli import parse_multiple_value_argument_from_str def test_from_arg(): @@ -54,4 +54,3 @@ def test_parsing_multiple_value_argument(): assert parse_multiple_value_argument_from_str("2, 3") == [2, 3] assert parse_multiple_value_argument_from_str("3") == [3] assert parse_multiple_value_argument_from_str("") == [] - diff --git a/game/tests/map.py b/game/tests/map.py index 286cd15..c2dcede 100644 --- a/game/tests/map.py +++ b/game/tests/map.py @@ -17,6 +17,7 @@ def test_neighbour_count_condition_2(): neighbours = game_map.count_neighbours(1, 2) assert neighbours == excepted_neighbours + def test_neighbour_count_fail(): excepted_neighbours = 1 game_map = Map() diff --git a/game/utils/map.py b/game/utils/map.py index 9f6c157..885aba5 100644 --- a/game/utils/map.py +++ b/game/utils/map.py @@ -154,7 +154,9 @@ def count_neighbours(self, x, y) -> int | None: for neighbouring_y in range(y - self.neighbouring_condition, y + (self.neighbouring_condition + 1)): if 0 <= neighbouring_y < len(self.map): for neighbouring_x in range(x - self.neighbouring_condition, x + (self.neighbouring_condition + 1)): - if 0 <= neighbouring_x < len(self.map[neighbouring_y]) and (neighbouring_y != y or neighbouring_x != x): + if 0 <= neighbouring_x < len(self.map[neighbouring_y]) and ( + neighbouring_y != y or neighbouring_x != x + ): if self.map[neighbouring_y][neighbouring_x]: neighbours += 1 From ba9980ecabe9f375226677268c5a0cc02dec6d7b Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 18 Jun 2025 21:26:24 +0200 Subject: [PATCH 5/6] fix: Include all tests in pytest --- game/tests/{cli => test_cli}/__init__.py | 0 game/tests/{cli => test_cli}/expected_display.txt | 0 game/tests/{cli/cli.py => test_cli/test_cli.py} | 8 ++++---- game/tests/{cli => test_cli}/test_map.txt | 0 game/tests/{map.py => test_map.py} | 0 pyproject.toml | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename game/tests/{cli => test_cli}/__init__.py (100%) rename game/tests/{cli => test_cli}/expected_display.txt (100%) rename game/tests/{cli/cli.py => test_cli/test_cli.py} (83%) rename game/tests/{cli => test_cli}/test_map.txt (100%) rename game/tests/{map.py => test_map.py} (100%) diff --git a/game/tests/cli/__init__.py b/game/tests/test_cli/__init__.py similarity index 100% rename from game/tests/cli/__init__.py rename to game/tests/test_cli/__init__.py diff --git a/game/tests/cli/expected_display.txt b/game/tests/test_cli/expected_display.txt similarity index 100% rename from game/tests/cli/expected_display.txt rename to game/tests/test_cli/expected_display.txt diff --git a/game/tests/cli/cli.py b/game/tests/test_cli/test_cli.py similarity index 83% rename from game/tests/cli/cli.py rename to game/tests/test_cli/test_cli.py index 54f5f2c..fe27f2f 100644 --- a/game/tests/cli/cli.py +++ b/game/tests/test_cli/test_cli.py @@ -18,7 +18,7 @@ def test_from_arg(): def test_from_file(): game_map = Map() - game_map.load_from_file("game/tests/test_map.txt") + game_map.load_from_file("game/tests/test_cli/test_map.txt") assert game_map.map == [ [False for _ in range(5)], @@ -34,18 +34,18 @@ def test_from_arg_and_from_file_are_identical(): game_map_from_arg.load_from_str("..... .### # ....# .") game_map_from_file = Map() - game_map_from_file.load_from_file("game/tests/cli/test_map.txt") + game_map_from_file.load_from_file("game/tests/test_cli/test_map.txt") assert game_map_from_arg.map == game_map_from_file.map def test_tui(): game_map = Map() - game_map.load_from_file("game/tests/cli/test_map.txt") + game_map.load_from_file("game/tests/test_cli/test_map.txt") display = prepare_display(game_map, state={"paused": False}) - expected_display = open("game/tests/cli/expected_display.txt").read() + expected_display = open("game/tests/test_cli/expected_display.txt").read() assert display == expected_display diff --git a/game/tests/cli/test_map.txt b/game/tests/test_cli/test_map.txt similarity index 100% rename from game/tests/cli/test_map.txt rename to game/tests/test_cli/test_map.txt diff --git a/game/tests/map.py b/game/tests/test_map.py similarity index 100% rename from game/tests/map.py rename to game/tests/test_map.py diff --git a/pyproject.toml b/pyproject.toml index 4dfffd3..8964932 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ packages = ["game"] test = ["pytest"] [tool.pytest.ini_options] -testpaths = ["game/tests/*"] +testpaths = ["game/tests"] [project.scripts] game = "game.main:app" From 6dab7c3f9360788d72bcb34cfd87daf64fe9d2c1 Mon Sep 17 00:00:00 2001 From: Kubaryt Date: Wed, 18 Jun 2025 21:31:42 +0200 Subject: [PATCH 6/6] chore: Stupid import inside function definition, so I don't have to struggle with tests not running on github actions --- game/utils/tui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/game/utils/tui.py b/game/utils/tui.py index 73f0d22..ce7b6ac 100644 --- a/game/utils/tui.py +++ b/game/utils/tui.py @@ -2,7 +2,6 @@ from copy import deepcopy from time import sleep -from pynput import keyboard from rich.console import Console from rich.live import Live @@ -26,6 +25,10 @@ def prepare_display(game_map: Map, state: dict) -> str: def detect_key_input(state: dict, lock): + from pynput import ( + keyboard, # todo: fix this, this is placed here to avoid errors when running tests on github actions + ) + def on_press(key): try: if key.char == "p":