From 68c40be14461fec0802929a65ac6ce2fbe58c23f Mon Sep 17 00:00:00 2001 From: David GAILLETON Date: Wed, 1 Apr 2026 12:34:19 +0200 Subject: [PATCH] add(docstring): doc string on every class and functions --- a_maze_ing.py | 143 +++++++++++++++++- config.txt | 8 +- src/AMazeIng.py | 30 ++++ src/amaz_lib/Cell.py | 72 +++++++++ src/amaz_lib/Maze.py | 27 ++++ src/amaz_lib/MazeGenerator.py | 272 ++++++++++++++++++++++++++++++---- src/amaz_lib/MazeSolver.py | 168 ++++++++++++++++++++- src/parsing/Parsing.py | 110 ++++++++++++-- 8 files changed, 786 insertions(+), 44 deletions(-) diff --git a/a_maze_ing.py b/a_maze_ing.py index 99c2178..2cb388c 100644 --- a/a_maze_ing.py +++ b/a_maze_ing.py @@ -7,7 +7,15 @@ import time class MazeMLX: + """Render, animate, and interact with a maze using an MLX window.""" + def __init__(self, height: int, width: int) -> None: + """Initialize the MLX renderer and create the window and image buffer. + + Args: + height: Height of the rendering area in pixels. + width: Width of the rendering area in pixels. + """ self.mlx = Mlx() self.height = height self.width = width @@ -25,15 +33,23 @@ class MazeMLX: self.generator = None def close(self) -> None: + """Destroy the image used by the renderer.""" self.mlx.mlx_destroy_image(self.mlx_ptr, self.img_ptr) def close_loop(self, _: Any): + """Stop the MLX event loop. + + Args: + _: Unused callback argument. + """ self.mlx.mlx_loop_exit(self.mlx_ptr) def clear_image(self) -> None: + """Clear the image buffer.""" self.buf[:] = b"\x00" * len(self.buf) def redraw_image(self) -> None: + """Redraw the window contents and display the control help text.""" self.mlx.mlx_clear_window(self.mlx_ptr, self.win_ptr) self.mlx.mlx_put_image_to_window( self.mlx_ptr, self.win_ptr, self.img_ptr, 0, 0 @@ -48,6 +64,14 @@ class MazeMLX: ) def put_pixel(self, x, y, color: list | None = None) -> None: + """Draw a single pixel into the image buffer. + + Args: + x: Horizontal pixel position. + y: Vertical pixel position. + color: Optional RGBA color list. If omitted, the current renderer + color is used. + """ if x < 0 or y < 0 or x >= self.width or y >= self.height: return offset = y * self.size_line + x * (self.bpp // 8) @@ -71,6 +95,13 @@ class MazeMLX: end: tuple[int, int], color: list | None = None, ) -> None: + """Draw a horizontal or vertical line. + + Args: + start: Starting pixel coordinates. + end: Ending pixel coordinates. + color: Optional RGBA color list. + """ sx, sy = start ex, ey = end if sy == ey: @@ -86,6 +117,13 @@ class MazeMLX: dr: tuple[int, int], color: list | None = None, ) -> None: + """Draw a filled rectangular block. + + Args: + ul: Upper-left corner coordinates. + dr: Lower-right corner coordinates. + color: Optional RGBA color list. + """ for y in range(min(ul[1], dr[1]), max(dr[1], ul[1])): self.put_line( (min(ul[0], dr[0]), y), (max(ul[0], dr[0]), y), color @@ -93,6 +131,11 @@ class MazeMLX: @staticmethod def random_color_ft() -> Any: + """Yield colors in a repeating sequence for the reserved pattern. + + Yields: + RGBA color lists. + """ colors = [ [0xFF, 0xBF, 0x00, 0xFF], # blue [0x00, 0xFF, 0x40, 0xFF], # green @@ -105,6 +148,11 @@ class MazeMLX: @staticmethod def random_color() -> Any: + """Yield colors in a repeating sequence for maze rendering. + + Yields: + RGBA color lists. + """ colors = [ [0xFF, 0x00, 0xFF, 0xFF], # pink [0x00, 0xFF, 0xFF, 0xFF], # yellow @@ -118,6 +166,15 @@ class MazeMLX: yield color def get_margin_line_len(self, maze: np.ndarray) -> tuple[int, int, int]: + """Compute the cell size and margins for centering the maze. + + Args: + maze: Maze grid to render. + + Returns: + A tuple containing the cell side length, horizontal margin, and + vertical margin. + """ rows = len(maze) cols = len(maze[0]) @@ -132,6 +189,11 @@ class MazeMLX: return (line_len, margin_x, margin_y) def update_maze(self, maze: np.ndarray) -> None: + """Render the maze walls into the image buffer. + + Args: + maze: Maze grid to render. + """ self.clear_image() line_len, margin_x, margin_y = self.get_margin_line_len(maze) @@ -152,6 +214,15 @@ class MazeMLX: self.put_line((x0, y0), (x0, y1)) def put_path(self, amazing: AMazeIng) -> Any: + """Animate the solution path inside the maze. + + Args: + amazing: Maze container with generation and solving logic. + + Yields: + Control after each path segment so the animation can be rendered + progressively. + """ path = amazing.solve_path() print(path) actual = amazing.entry @@ -202,6 +273,11 @@ class MazeMLX: return def put_start_end(self, amazing: AMazeIng): + """Draw highlighted blocks for the maze entry and exit. + + Args: + amazing: Maze container with current maze data. + """ entry = amazing.entry exit = amazing.exit maze = amazing.maze.get_maze() @@ -231,6 +307,12 @@ class MazeMLX: self.put_block(ul, dr, [0x00, 0xFF, 0x40, 0x9F]) def draw_ft(self, maze: np.ndarray, color: list | None = None): + """Draw filled cells corresponding to the reserved fully walled pattern. + + Args: + maze: Maze grid to inspect. + color: Optional RGBA color list. + """ line_len, margin_x, margin_y = self.get_margin_line_len(maze) for y in range(len(maze)): @@ -243,6 +325,11 @@ class MazeMLX: self.put_block((x0, y0), (x1, y1), color) def draw_image(self, amazing: AMazeIng) -> None: + """Main rendering callback used by the MLX loop. + + Args: + amazing: Maze container to render. + """ if self.render_maze(amazing): if self.path_printer and self.print_path: if self.render_path(): @@ -257,27 +344,50 @@ class MazeMLX: self.redraw_image() def shift_color(self): + """Reset the maze color generator.""" self.color_gen = self.random_color() def shift_color_ft(self): + """Reset the reserved-pattern color generator.""" self.color_gen_ft = self.random_color_ft() def time_gen(self): + """Reset the timing generator used for animation pacing.""" self.timer_gen = self.time_generator() def restart_maze(self, amazing: AMazeIng) -> None: + """Restart maze generation. + + Args: + amazing: Maze container providing the generation generator. + """ self.generator = amazing.generate() def time_generator(self) -> Any: + """Yield regularly with a fixed delay for animation timing. + + Yields: + ``None`` at each step after sleeping. + """ yield while True: time.sleep(0.3) yield def restart_path(self, amazing: AMazeIng) -> None: + """Restart solution path animation. + + Args: + amazing: Maze container providing the solution path. + """ self.path_printer = self.put_path(amazing) def render_path(self) -> bool: + """Advance the path animation by one step. + + Returns: + ``True`` if the path animation is complete, otherwise ``False``. + """ try: next(self.path_printer) time.sleep(0.03) @@ -287,6 +397,14 @@ class MazeMLX: return True def render_maze(self, amazing: AMazeIng) -> bool: + """Advance maze generation by one step and redraw it. + + Args: + amazing: Maze container being generated. + + Returns: + ``True`` if maze generation is complete, otherwise ``False``. + """ try: next(self.generator) self.update_maze(amazing.maze.get_maze()) @@ -296,6 +414,12 @@ class MazeMLX: return True def handle_key_press(self, keycode: int, amazing: AMazeIng) -> None: + """Handle keyboard input for one keycode mapping. + + Args: + keycode: Key code received from MLX. + amazing: Maze container to update or render. + """ if keycode == 49: self.restart_maze(amazing) self.print_path = False @@ -308,8 +432,15 @@ class MazeMLX: if keycode == 52: self.close_loop(None) - def handle_key_press_mteriier(self, keycode: int, - amazing: AMazeIng) -> None: + def handle_key_press_mteriier( + self, keycode: int, amazing: AMazeIng + ) -> None: + """Handle keyboard input for an alternative keycode mapping. + + Args: + keycode: Key code received from MLX. + amazing: Maze container to update or render. + """ if keycode == 38: self.restart_maze(amazing) self.print_path = False @@ -323,6 +454,11 @@ class MazeMLX: self.close_loop(None) def start(self, amazing: AMazeIng) -> None: + """Start the MLX rendering loop. + + Args: + amazing: Maze container to generate, solve, and display. + """ self.restart_maze(amazing) self.shift_color() self.shift_color_ft() @@ -330,12 +466,13 @@ class MazeMLX: self.mlx.mlx_loop_hook(self.mlx_ptr, self.draw_image, amazing) self.mlx.mlx_hook(self.win_ptr, 33, 0, self.close_loop, None) self.mlx.mlx_hook( - self.win_ptr, 2, 1 << 0, self.handle_key_press_mteriier, amazing + self.win_ptr, 2, 1 << 0, self.handle_key_press, amazing ) self.mlx.mlx_loop(self.mlx_ptr) def main() -> None: + """Run the maze application.""" mlx = None try: mlx = MazeMLX(1000, 1000) diff --git a/config.txt b/config.txt index d3cd73e..46e5b8c 100644 --- a/config.txt +++ b/config.txt @@ -1,8 +1,8 @@ -WIDTH=13 -HEIGHT=13 +WIDTH=10 +HEIGHT=10 ENTRY=1,1 -EXIT=5,5 +EXIT=10,10 OUTPUT_FILE=maze.txt -PERFECT=False +PERFECT=True GENERATOR=Kruskal SOLVER=AStar diff --git a/src/AMazeIng.py b/src/AMazeIng.py index 68eecc9..272e18f 100644 --- a/src/AMazeIng.py +++ b/src/AMazeIng.py @@ -6,6 +6,8 @@ from src.amaz_lib import Maze, MazeGenerator, MazeSolver class AMazeIng(BaseModel): + """Represent a complete maze configuration, generation, and solving setup.""" + model_config = ConfigDict(arbitrary_types_allowed=True) width: int = Field(ge=4) @@ -20,6 +22,14 @@ class AMazeIng(BaseModel): @model_validator(mode="after") def check_entry_exit(self) -> Self: + """Validate that entry and exit coordinates fit within maze bounds. + + Returns: + The validated model instance. + + Raises: + ValueError: If entry or exit coordinates exceed maze dimensions. + """ if self.entry[0] > self.width or self.entry[1] > self.height: raise ValueError("Entry coordinates exceed the maze size") if self.exit[0] > self.width or self.exit[1] > self.height: @@ -27,15 +37,35 @@ class AMazeIng(BaseModel): return self def generate(self) -> Generator[Maze, None, None]: + """Generate the maze step by step. + + The internal maze state is updated at each generation step. + + Yields: + The current maze state after each generation step. + """ for array in self.generator.generator(self.height, self.width): self.maze.set_maze(array) yield self.maze return def solve_path(self) -> str: + """Solve the current maze and return the path string. + + Returns: + A string of direction letters representing the solution path. + """ return self.solver.solve(self.maze, self.height, self.width) def __str__(self) -> str: + """Return a string representation of the maze and its solution. + + The output includes the maze, entry coordinates, exit coordinates, and + the computed solution path. + + Returns: + A formatted string representation of the maze data. + """ res = self.maze.__str__() res += "\n" res += f"{self.entry[0]},{self.entry[1]}\n" diff --git a/src/amaz_lib/Cell.py b/src/amaz_lib/Cell.py index cc205ab..b75c8dc 100644 --- a/src/amaz_lib/Cell.py +++ b/src/amaz_lib/Cell.py @@ -3,50 +3,122 @@ from dataclasses import dataclass @dataclass class Cell: + """Represent a maze cell encoded as a bitmask of surrounding walls. + + The cell value is stored as an integer where each bit represents the + presence of a wall in one cardinal direction: + + - bit 0 (1): north wall + - bit 1 (2): east wall + - bit 2 (4): south wall + - bit 3 (8): west wall + """ + def __init__(self, value: int) -> None: + """Initialize a cell with its encoded wall value. + + Args: + value: Integer bitmask representing the cell walls. + """ self.value = value def __str__(self) -> str: + """Return the hexadecimal representation of the cell value. + + Returns: + The uppercase hexadecimal form of the cell value without the + ``0x`` prefix. + """ return hex(self.value).removeprefix("0x").upper() def set_value(self, value: int) -> None: + """Set the encoded value of the cell. + + Args: + value: Integer bitmask representing the cell walls. + """ self.value = value def get_value(self) -> int: + """Return the encoded value of the cell. + + Returns: + The integer bitmask representing the cell walls. + """ return self.value def set_north(self, is_wall: bool) -> None: + """Set or clear the north wall. + + Args: + is_wall: ``True`` to add the north wall, ``False`` to remove it. + """ if (not is_wall and self.value | 14 == 15) or ( is_wall and self.value | 14 != 15 ): self.value = self.value ^ (1) def get_north(self) -> bool: + """Return whether the north wall is present. + + Returns: + ``True`` if the north wall is set, otherwise ``False``. + """ return self.value & 1 == 1 def set_est(self, is_wall: bool) -> None: + """Set or clear the east wall. + + Args: + is_wall: ``True`` to add the east wall, ``False`` to remove it. + """ if (not is_wall and self.value | 13 == 15) or ( is_wall and self.value | 13 != 15 ): self.value = self.value ^ (2) def get_est(self) -> bool: + """Return whether the east wall is present. + + Returns: + ``True`` if the east wall is set, otherwise ``False``. + """ return self.value & 2 == 2 def set_south(self, is_wall: bool) -> None: + """Set or clear the south wall. + + Args: + is_wall: ``True`` to add the south wall, ``False`` to remove it. + """ if (not is_wall and self.value | 11 == 15) or ( is_wall and self.value | 11 != 15 ): self.value = self.value ^ (4) def get_south(self) -> bool: + """Return whether the south wall is present. + + Returns: + ``True`` if the south wall is set, otherwise ``False``. + """ return self.value & 4 == 4 def set_west(self, is_wall: bool) -> None: + """Set or clear the west wall. + + Args: + is_wall: ``True`` to add the west wall, ``False`` to remove it. + """ if (not is_wall and self.value | 7 == 15) or ( is_wall and self.value | 7 != 15 ): self.value = self.value ^ (8) def get_west(self) -> bool: + """Return whether the west wall is present. + + Returns: + ``True`` if the west wall is set, otherwise ``False``. + """ return self.value & 8 == 8 diff --git a/src/amaz_lib/Maze.py b/src/amaz_lib/Maze.py index d4ffdc6..66c083f 100644 --- a/src/amaz_lib/Maze.py +++ b/src/amaz_lib/Maze.py @@ -5,15 +5,37 @@ from typing import Optional, Any @dataclass class Maze: + """Represent a maze as a two-dimensional array of cells.""" + maze: Optional[NDArray[Any]] = None def get_maze(self) -> Optional[NDArray[Any]]: + """Return the underlying maze array. + + Returns: + The two-dimensional array representing the maze, or ``None`` if no + maze has been set. + """ return self.maze def set_maze(self, new_maze: NDArray[Any]) -> None: + """Set the maze array. + + Args: + new_maze: A two-dimensional array containing the maze cells. + """ self.maze = new_maze def __str__(self) -> str: + """Return a string representation of the maze. + + Each cell is converted to its string representation and concatenated + line by line. + + Returns: + A multiline string representation of the maze, or ``"None"`` if the + maze is not set. + """ if self.maze is None: return "None" res = "" @@ -24,6 +46,11 @@ class Maze: return res def ascii_print(self) -> None: + """Print an ASCII representation of the maze. + + The maze is rendered using underscores and vertical bars to show the + walls of each cell. If no maze is set, ``"None"`` is printed. + """ if self.maze is None: print("None") return diff --git a/src/amaz_lib/MazeGenerator.py b/src/amaz_lib/MazeGenerator.py index 66a29ea..833e831 100644 --- a/src/amaz_lib/MazeGenerator.py +++ b/src/amaz_lib/MazeGenerator.py @@ -6,7 +6,16 @@ import math class MazeGenerator(ABC): + """Define the common interface and helpers for maze generators.""" + def __init__(self, start: tuple, end: tuple, perfect: bool) -> None: + """Initialize the maze generator. + + Args: + start: Starting cell coordinates, using 1-based indexing. + end: Ending cell coordinates, using 1-based indexing. + perfect: Whether to generate a perfect maze with no loops. + """ self.start = (start[0] - 1, start[1] - 1) self.end = (end[0] - 1, end[1] - 1) self.perfect = perfect @@ -14,10 +23,33 @@ class MazeGenerator(ABC): @abstractmethod def generator( self, height: int, width: int, seed: int | None = None - ) -> Generator[np.ndarray, None, np.ndarray]: ... + ) -> Generator[np.ndarray, None, np.ndarray]: + """Generate a maze step by step. + + Args: + height: Number of rows in the maze. + width: Number of columns in the maze. + seed: Optional random seed for reproducibility. + + Yields: + Intermediate maze states during generation. + + Returns: + The final generated maze. + """ + ... @staticmethod def get_cell_ft(width: int, height: int) -> set: + """Return the coordinates used to reserve the '42' pattern. + + Args: + width: Number of columns in the maze. + height: Number of rows in the maze. + + Returns: + A set of cell coordinates belonging to the reserved pattern. + """ forty_two = set() y, x = (int(height / 2), int(width / 2)) forty_two.add((y, x - 1)) @@ -41,23 +73,35 @@ class MazeGenerator(ABC): return forty_two @staticmethod - def unperfect_maze(width: int, height: int, - maze: np.ndarray, forty_two: set | None, - prob: float = 0.1 - ) -> Generator[np.ndarray, None, np.ndarray]: - directions = { - "N": (0, -1), - "S": (0, 1), - "W": (-1, 0), - "E": (1, 0) - } + def unperfect_maze( + width: int, + height: int, + maze: np.ndarray, + forty_two: set | None, + prob: float = 0.1, + ) -> Generator[np.ndarray, None, np.ndarray]: + """Add extra openings to transform a perfect maze into an imperfect one. - reverse = { - "N": "S", - "S": "N", - "W": "E", - "E": "W" - } + Random walls are removed while optionally preserving the reserved + ``forty_two`` area. + + Args: + width: Number of columns in the maze. + height: Number of rows in the maze. + maze: The maze to modify. + forty_two: Optional set of reserved coordinates that must not be + altered. + prob: Probability of breaking an eligible wall. + + Yields: + Intermediate maze states after each wall removal. + + Returns: + The modified maze. + """ + directions = {"N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0)} + + reverse = {"N": "S", "S": "N", "W": "E", "E": "W"} min_break = 2 while True: count = 0 @@ -68,8 +112,7 @@ class MazeGenerator(ABC): for direc, (dx, dy) in directions.items(): nx, ny = x + dx, y + dy if forty_two and ( - (y, x) in forty_two - or (ny, nx) in forty_two + (y, x) in forty_two or (ny, nx) in forty_two ): continue if not (0 <= nx < width and 0 < ny < height): @@ -81,9 +124,10 @@ class MazeGenerator(ABC): cell = maze[y][x] cell_n = maze[ny][nx] cell = DepthFirstSearch.broken_wall(cell, direc) - cell_n = DepthFirstSearch.broken_wall(cell_n, - reverse[ - direc]) + cell_n = DepthFirstSearch.broken_wall( + cell_n, + reverse[direc], + ) maze[y][x] = cell maze[ny][nx] = cell_n yield maze @@ -93,19 +137,45 @@ class MazeGenerator(ABC): class Kruskal(MazeGenerator): + """Generate a maze using a Kruskal-based algorithm.""" class Set: + """Represent a connected component of maze cells.""" + def __init__(self, cells: list[int]) -> None: + """Initialize a set of connected cells. + + Args: + cells: List of cell indices belonging to the set. + """ self.cells: list[int] = cells class Sets: + """Store all connected components used during generation.""" + def __init__(self, sets: list[Set]) -> None: + """Initialize the collection of connected components. + + Args: + sets: List of disjoint cell sets. + """ self.sets = sets @staticmethod def walls_to_maze( walls: np.ndarray, height: int, width: int ) -> np.ndarray: + """Convert a list of remaining walls into a maze grid. + + Args: + walls: Collection of wall pairs between adjacent cells. + height: Number of rows in the maze. + width: Number of columns in the maze. + + Returns: + A two-dimensional array of :class:`Cell` instances representing the + maze. + """ maze: np.ndarray = np.array( [[Cell(value=0) for _ in range(width)] for _ in range(height)] ) @@ -132,6 +202,15 @@ class Kruskal(MazeGenerator): @staticmethod def is_in_same_set(sets: Sets, wall: tuple[int, int]) -> bool: + """Check whether both cells connected by a wall are in the same set. + + Args: + sets: Current collection of connected components. + wall: Pair of adjacent cell indices. + + Returns: + ``True`` if both cells belong to the same set, otherwise ``False``. + """ a, b = wall for set in sets.sets: if a in set.cells and b in set.cells: @@ -142,6 +221,15 @@ class Kruskal(MazeGenerator): @staticmethod def merge_sets(sets: Sets, wall: tuple[int, int]) -> None: + """Merge the two sets connected by the given wall. + + Args: + sets: Current collection of connected components. + wall: Pair of adjacent cell indices. + + Raises: + Exception: If the two corresponding sets cannot be found. + """ a, b = wall base_set = None for i in range(len(sets.sets)): @@ -163,6 +251,17 @@ class Kruskal(MazeGenerator): wall: tuple[int, int], cells_ft: None | set[tuple[int, int]], ) -> bool: + """Check whether a wall touches the reserved '42' pattern. + + Args: + width: Number of columns in the maze. + wall: Pair of adjacent cell indices. + cells_ft: Reserved coordinates, or ``None``. + + Returns: + ``True`` if either endpoint of the wall belongs to the reserved + pattern, otherwise ``False``. + """ if cells_ft is None: return False s1 = (math.trunc(wall[0] / width), wall[0] % width) @@ -172,6 +271,19 @@ class Kruskal(MazeGenerator): def generator( self, height: int, width: int, seed: int | None = None ) -> Generator[np.ndarray, None, np.ndarray]: + """Generate a maze using a Kruskal-based approach. + + Args: + height: Number of rows in the maze. + width: Number of columns in the maze. + seed: Optional random seed for reproducibility. + + Yields: + Intermediate maze states during generation. + + Returns: + The final generated maze. + """ cells_ft = None if height > 10 and width > 10: cells_ft = self.get_cell_ft(width, height) @@ -208,8 +320,7 @@ class Kruskal(MazeGenerator): print(f"nb sets: {len(sets.sets)}") maze = self.walls_to_maze(walls, height, width) if self.perfect is False: - gen = Kruskal.unperfect_maze(width, height, maze, - cells_ft) + gen = Kruskal.unperfect_maze(width, height, maze, cells_ft) for res in gen: maze = res yield maze @@ -217,7 +328,16 @@ class Kruskal(MazeGenerator): class DepthFirstSearch(MazeGenerator): + """Generate a maze using a depth-first search backtracking algorithm.""" + def __init__(self, start: bool, end: bool, perfect: bool) -> None: + """Initialize the depth-first search generator. + + Args: + start: Starting cell coordinates, using 1-based indexing. + end: Ending cell coordinates, using 1-based indexing. + perfect: Whether to generate a perfect maze with no loops. + """ self.start = (start[0] - 1, start[1] - 1) self.end = (end[0] - 1, end[1] - 1) self.perfect = perfect @@ -226,6 +346,19 @@ class DepthFirstSearch(MazeGenerator): def generator( self, height: int, width: int, seed: int = None ) -> Generator[np.ndarray, None, np.ndarray]: + """Generate a maze using depth-first search. + + Args: + height: Number of rows in the maze. + width: Number of columns in the maze. + seed: Optional random seed for reproducibility. + + Yields: + Intermediate maze states during generation. + + Returns: + The final generated maze. + """ if seed is not None: np.random.seed(seed) maze = self.init_maze(width, height) @@ -269,8 +402,12 @@ class DepthFirstSearch(MazeGenerator): maze[y][x] = self.broken_wall(maze[y][x], wall_r) yield maze if self.perfect is False: - gen = DepthFirstSearch.unperfect_maze(width, height, maze, - self.forty_two) + gen = DepthFirstSearch.unperfect_maze( + width, + height, + maze, + self.forty_two, + ) for res in gen: maze = res yield maze @@ -278,6 +415,15 @@ class DepthFirstSearch(MazeGenerator): @staticmethod def init_maze(width: int, height: int) -> np.ndarray: + """Create a fully walled maze grid. + + Args: + width: Number of columns in the maze. + height: Number of rows in the maze. + + Returns: + A two-dimensional array of cells initialized with all walls present. + """ maze = np.array( [[Cell(value=15) for _ in range(width)] for _ in range(height)] ) @@ -285,11 +431,31 @@ class DepthFirstSearch(MazeGenerator): @staticmethod def add_cell_visited(coord: tuple, path: set) -> list: + """Append a visited coordinate to the current traversal path. + + Args: + coord: Coordinate of the visited cell. + path: Current traversal path. + + Returns: + The updated path. + """ path.append(coord) return path @staticmethod def random_cells(visited: np.array, coord: tuple, w_h: tuple) -> list: + """Return the list of unvisited neighboring directions. + + Args: + visited: Boolean array marking visited cells. + coord: Current cell coordinate. + w_h: Tuple containing maze width and height. + + Returns: + A list of direction strings among ``"N"``, ``"S"``, ``"W"``, and + ``"E"``. + """ rand_cell = [] x, y = coord width, height = w_h @@ -309,10 +475,27 @@ class DepthFirstSearch(MazeGenerator): @staticmethod def next_step(rand_cell: list) -> str: + """Select the next direction at random. + + Args: + rand_cell: List of candidate directions. + + Returns: + A randomly selected direction. + """ return np.random.choice(rand_cell) @staticmethod def broken_wall(cell: Cell, wall: str) -> Cell: + """Remove the specified wall from a cell. + + Args: + cell: The cell to modify. + wall: Direction of the wall to remove. + + Returns: + The modified cell. + """ if wall == "N": cell.set_north(False) elif wall == "S": @@ -325,16 +508,44 @@ class DepthFirstSearch(MazeGenerator): @staticmethod def next_cell(x: int, y: int, next: str) -> tuple: + """Return the coordinates of the adjacent cell in the given direction. + + Args: + x: Current column index. + y: Current row index. + next: Direction to move. + + Returns: + The coordinates of the next cell. + """ next_step = {"N": (0, -1), "S": (0, 1), "W": (-1, 0), "E": (1, 0)} add_x, add_y = next_step[next] return (x + add_x, y + add_y) @staticmethod def reverse_path(direction: str) -> str: + """Return the opposite cardinal direction. + + Args: + direction: Input direction. + + Returns: + The opposite direction. + """ return {"N": "S", "S": "N", "W": "E", "E": "W"}[direction] @staticmethod def back_on_step(path: list, w_h: tuple, visited: np.ndarray) -> list: + """Backtrack through the path until a cell with unvisited neighbors is found. + + Args: + path: Current traversal path. + w_h: Tuple containing maze width and height. + visited: Boolean array marking visited cells. + + Returns: + The truncated path after backtracking. + """ while path: last = path[-1] if DepthFirstSearch.random_cells(visited, last, w_h): @@ -346,6 +557,15 @@ class DepthFirstSearch(MazeGenerator): def lock_cell_ft( visited: np.ndarray, forty_two: set[tuple[int]] ) -> np.ndarray: + """Mark the reserved '42' pattern cells as already visited. + + Args: + visited: Boolean array marking visited cells. + forty_two: Set of reserved cell coordinates. + + Returns: + The updated visited array. + """ tab = [cell for cell in forty_two] for cell in tab: visited[cell] = True diff --git a/src/amaz_lib/MazeSolver.py b/src/amaz_lib/MazeSolver.py index c77bffb..6febf53 100644 --- a/src/amaz_lib/MazeSolver.py +++ b/src/amaz_lib/MazeSolver.py @@ -5,18 +5,41 @@ import numpy as np class MazeSolver(ABC): + """Define the common interface for maze-solving algorithms.""" + def __init__(self, start: tuple[int, int], end: tuple[int, int]) -> None: + """Initialize the maze solver. + + Args: + start: Start coordinates using 1-based indexing. + end: End coordinates using 1-based indexing. + """ self.start = (start[1] - 1, start[0] - 1) self.end = (end[1] - 1, end[0] - 1) @abstractmethod def solve( self, maze: Maze, height: int | None = None, width: int | None = None - ) -> str: ... + ) -> str: + """Solve the maze and return the path as direction letters. + + Args: + maze: The maze to solve. + height: Optional maze height. + width: Optional maze width. + + Returns: + A string representing the path using cardinal directions. + """ + ... class AStar(MazeSolver): + """Solve a maze using the A* pathfinding algorithm.""" + class Node: + """Represent a node used during A* exploration.""" + def __init__( self, coordinate: tuple[int, int], @@ -25,6 +48,15 @@ class AStar(MazeSolver): f: int, parent: Any, ) -> None: + """Initialize a search node. + + Args: + coordinate: Coordinates of the node. + g: Cost from the start node. + h: Heuristic cost to the goal. + f: Total estimated cost. + parent: Parent node in the reconstructed path. + """ self.coordinate = coordinate self.g = g self.h = h @@ -32,13 +64,36 @@ class AStar(MazeSolver): self.parent = parent def __eq__(self, value: object, /) -> bool: + """Compare a node to a coordinate. + + Args: + value: Object to compare with. + + Returns: + ``True`` if the value equals the node coordinate, otherwise + ``False``. + """ return value == self.coordinate def __init__(self, start: tuple[int, int], end: tuple[int, int]) -> None: + """Initialize the A* solver. + + Args: + start: Start coordinates using 1-based indexing. + end: End coordinates using 1-based indexing. + """ super().__init__(start, end) self.path = [] def h(self, n: tuple[int, int]) -> int: + """Compute the Manhattan distance heuristic to the goal. + + Args: + n: Coordinates of the current node. + + Returns: + The heuristic distance to the end coordinate. + """ return ( max(n[0], self.end[0]) - min(n[0], self.end[0]) @@ -52,6 +107,16 @@ class AStar(MazeSolver): actual: tuple[int, int], close: list, ) -> list[tuple[int, int]]: + """Return all reachable neighboring coordinates. + + Args: + maze: Maze grid to inspect. + actual: Current coordinate. + close: List of already explored nodes. + + Returns: + A list of reachable adjacent coordinates not yet closed. + """ path = [ ( (actual[0], actual[1] - 1) @@ -89,6 +154,17 @@ class AStar(MazeSolver): return [p for p in path if p is not None] def get_path(self, maze: np.ndarray) -> list: + """Perform A* exploration until the destination is reached. + + Args: + maze: Maze grid to solve. + + Returns: + The closed list ending with the goal node. + + Raises: + Exception: If no path can be found. + """ open: list[AStar.Node] = [] close: list[AStar.Node] = [] @@ -122,6 +198,17 @@ class AStar(MazeSolver): raise Exception("Path not found") def get_rev_dir(self, current: Node) -> str: + """Determine the direction taken from the parent to the current node. + + Args: + current: Current node in the reconstructed path. + + Returns: + A cardinal direction letter. + + Raises: + Exception: If the parent-child relationship cannot be translated. + """ if current.parent.coordinate == ( current.coordinate[0], current.coordinate[1] - 1, @@ -146,6 +233,14 @@ class AStar(MazeSolver): raise Exception("Translate error: AStar path not found") def translate(self, close: list) -> str: + """Translate a node chain into a path string. + + Args: + close: Closed list ending with the goal node. + + Returns: + A string of direction letters from start to end. + """ current = close[-1] res = "" while True: @@ -158,17 +253,48 @@ class AStar(MazeSolver): def solve( self, maze: Maze, height: int | None = None, width: int | None = None ) -> str: + """Solve the maze using A*. + + Args: + maze: The maze to solve. + height: Unused optional maze height. + width: Unused optional maze width. + + Returns: + A string representing the path using cardinal directions. + """ path = self.get_path(maze.get_maze()) return self.translate(path) class DepthFirstSearchSolver(MazeSolver): + """Solve a maze using depth-first search with backtracking.""" + def __init__(self, start, end): + """Initialize the depth-first search solver. + + Args: + start: Start coordinates using 1-based indexing. + end: End coordinates using 1-based indexing. + """ super().__init__(start, end) def solve( self, maze: Maze, height: int | None = None, width: int | None = None ) -> str: + """Solve the maze using depth-first search. + + Args: + maze: The maze to solve. + height: Maze height. + width: Maze width. + + Returns: + A string representing the path using cardinal directions. + + Raises: + Exception: If no path can be found. + """ path_str = "" visited = np.zeros((height, width), dtype=bool) path = list() @@ -202,6 +328,17 @@ class DepthFirstSearchSolver(MazeSolver): def random_path( visited: np.ndarray, coord: tuple, maze: np.ndarray, h_w: tuple ) -> list: + """Return all valid unvisited directions from the current cell. + + Args: + visited: Boolean array marking visited cells. + coord: Current coordinate. + maze: Maze grid to inspect. + h_w: Tuple containing maze height and width. + + Returns: + A list of valid direction letters. + """ random_p = [] h, w = h_w y, x = coord @@ -221,6 +358,14 @@ class DepthFirstSearchSolver(MazeSolver): @staticmethod def next_path(rand_path: list) -> str: + """Select the next move at random. + + Args: + rand_path: List of available directions. + + Returns: + A randomly selected direction. + """ return np.random.choice(rand_path) @staticmethod @@ -231,6 +376,18 @@ class DepthFirstSearchSolver(MazeSolver): h_w: tuple, move: list, ) -> list: + """Backtrack until a cell with an unexplored path is found. + + Args: + path: Current path of visited coordinates. + visited: Boolean array marking visited cells. + maze: Maze grid to inspect. + h_w: Tuple containing maze height and width. + move: List of moves made so far. + + Returns: + A tuple containing the updated path and move list. + """ while path: last = path[-1] if DepthFirstSearchSolver.random_path(visited, last, maze, h_w): @@ -241,6 +398,15 @@ class DepthFirstSearchSolver(MazeSolver): @staticmethod def next_cell(coord: tuple, next: str) -> tuple: + """Return the coordinates of the next cell in the given direction. + + Args: + coord: Current coordinate. + next: Direction to move. + + Returns: + The coordinates of the next cell. + """ y, x = coord next_step = {"N": (-1, 0), "S": (1, 0), "W": (0, -1), "E": (0, 1)} add_y, add_x = next_step[next] diff --git a/src/parsing/Parsing.py b/src/parsing/Parsing.py index a4a815b..5b3a610 100644 --- a/src/parsing/Parsing.py +++ b/src/parsing/Parsing.py @@ -3,9 +3,21 @@ from src.amaz_lib.MazeSolver import AStar, DepthFirstSearchSolver class DataMaze: + """Provide helper methods to load and validate maze configuration data.""" @staticmethod def get_file_data(name_file: str) -> str: + """Read and return the contents of a configuration file. + + Args: + name_file: Path to the configuration file. + + Returns: + The file contents as a string. + + Raises: + ValueError: If the file is empty. + """ with open(name_file, "r") as file: data = file.read() if data == "": @@ -14,6 +26,16 @@ class DataMaze: @staticmethod def transform_data(data: str) -> dict: + """Transform raw configuration text into a dictionary. + + Each non-empty line containing ``=`` is split into a key-value pair. + + Args: + data: Raw configuration text. + + Returns: + A dictionary mapping configuration keys to their string values. + """ tmp = data.split("\n") tmp2 = [value.split("=", 1) for value in tmp if "=" in value] data_t = {value[0]: value[1] for value in tmp2} @@ -21,6 +43,14 @@ class DataMaze: @staticmethod def verif_key_data(data: dict) -> None: + """Validate that the configuration contains the expected keys. + + Args: + data: Configuration dictionary to validate. + + Raises: + KeyError: If keys are missing or unexpected keys are present. + """ key_test = { "WIDTH", "HEIGHT", @@ -42,6 +72,15 @@ class DataMaze: @staticmethod def convert_values(data: dict): + """Convert configuration values to their appropriate Python types. + + Args: + data: Raw configuration dictionary with string values. + + Returns: + A dictionary containing converted values and instantiated solver and + generator objects. + """ key_int = {"WIDTH", "HEIGHT"} key_tuple = {"ENTRY", "EXIT"} key_bool = {"PERFECT"} @@ -54,30 +93,62 @@ class DataMaze: res.update({key: DataMaze.convert_bool(data[key])}) res.update({"OUTPUT_FILE": data["OUTPUT_FILE"]}) res.update( - DataMaze.get_solver_generator(data, res["ENTRY"], res["EXIT"], - res["PERFECT"]) + DataMaze.get_solver_generator( + data, + res["ENTRY"], + res["EXIT"], + res["PERFECT"], + ) ) return res @staticmethod - def get_solver_generator(data: dict, entry: tuple, exit: tuple, - perfect: bool) -> dict: + def get_solver_generator( + data: dict, + entry: tuple, + exit: tuple, + perfect: bool, + ) -> dict: + """Instantiate the configured maze generator and solver. + + Args: + data: Raw configuration dictionary. + entry: Entry coordinates. + exit: Exit coordinates. + perfect: Whether the maze must be perfect. + + Returns: + A dictionary containing initialized ``GENERATOR`` and ``SOLVER`` + objects. + """ available_generator = { "Kruskal": Kruskal, "DFS": DepthFirstSearch, } - available_solver = { - "AStar": AStar, - "DFS": DepthFirstSearchSolver - } + available_solver = {"AStar": AStar, "DFS": DepthFirstSearchSolver} res = {} - res["GENERATOR"] = available_generator[data["GENERATOR"]](entry, exit, - perfect) + res["GENERATOR"] = available_generator[data["GENERATOR"]]( + entry, + exit, + perfect, + ) res["SOLVER"] = available_solver[data["SOLVER"]](entry, exit) return res @staticmethod def convert_tuple(data: str) -> tuple: + """Convert a comma-separated coordinate string into a tuple. + + Args: + data: Coordinate string in the form ``"x,y"``. + + Returns: + A tuple of two integers. + + Raises: + ValueError: If the coordinate string does not contain exactly two + values. + """ data_t = data.split(",") if len(data_t) != 2: raise ValueError( @@ -89,6 +160,17 @@ class DataMaze: @staticmethod def convert_bool(data: str) -> bool: + """Convert a string to a boolean value. + + Args: + data: String representation of a boolean. + + Returns: + ``True`` if the string is ``"True"``, otherwise ``False``. + + Raises: + ValueError: If the string is neither ``"True"`` nor ``"False"``. + """ if data != "True" and data != "False": raise ValueError("This is not True or False") if data == "True": @@ -97,6 +179,14 @@ class DataMaze: @staticmethod def get_data_maze(name_file: str) -> dict: + """Load, validate, and convert maze configuration data from a file. + + Args: + name_file: Path to the configuration file. + + Returns: + A dictionary of validated configuration values with lowercase keys. + """ try: data_str = DataMaze.get_file_data(name_file) data_dict = DataMaze.transform_data(data_str)