diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..ff1ad85 --- /dev/null +++ b/.flake8 @@ -0,0 +1,21 @@ +[flake8] +max-line-length = 120 + +ignore = + E203, + W503, + E501, + +exclude = + .git, + __pycache__, + .venv, + .mypy_cache, + build, + dist, + migrations, + venv + +filename = *.py + +max-complexity = 12 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..e96aed1 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +.venv/ +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..26408c5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.6.1 + hooks: + - id: mypy + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + + - repo: https://github.com/psf/black + rev: 23.10.1 + hooks: + - id: black + + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + args: [--max-line-length=120] diff --git a/.venv/.gitignore b/.venv/.gitignore new file mode 100644 index 0000000..f50e9d4 --- /dev/null +++ b/.venv/.gitignore @@ -0,0 +1,2 @@ +# Created by venv; see https://docs.python.org/3/library/venv.html +.idea diff --git a/README.md b/README.md index 29a52c7..b0a7d74 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ -# Dots_Game \ No newline at end of file +# 🎮 Точки - классическая стратегическая игра + +## 🚀 Установка и запуск + +## 📋 Предварительные требования +- Python 3.9+ +- [Poetry](https://python-poetry.org/docs/#installation) (менеджер зависимостей) + +## ⚙️ Установка +```bash +git clone https://github.com/ваш-репозиторий/Dots_Game.git +cd Dots_Game +poetry install +``` + +## 🎮 Запуск игры +```bash +python main.py +``` + +## Для справки: + +```bash +poetry run python main.py help +``` + +## 🌟 Особенности + +- 🎯 Три режима игры: PvP, против простого ИИ и "умного" компьютера +- 🖌️ Гибкая настройка размера поля (до 30×33) +- 📊 Система рейтинга с JSON-таблицей лидеров +- 🎨 Интуитивный графический интерфейс на Tkinter +- ⚡ Оптимизированная игровая логика +- 🔍 Встроенная система помощи + +## 🎮 Режимы игры + +| Режим | Описание | +|:------------:|:---------------------------------------------:| +| **Игрок vs Игрок** | Классический вариант для двух игроков | +| **Простой ИИ** | Компьютер делает случайные ходы | +| **Умный ИИ** | Компьютер использует стратегию для победы | + +## 🏆 Правила игры +- Игроки ходят по очереди, ставя точки на пересечения линий +- Цель - окружить точки противника непрерывной цепью +- Окруженные точки становятся вашими +- Игра продолжается, пока есть свободные места или пока игроки не завершат игру +- Побеждает тот, кто захватил больше точек! + +## 🛠️ Структура проекта +Dots_Game/ +├── main.py # Точка входа +├── dots_game.py # Основной игровой класс +├── game_logic.py # Логика игры и ИИ +├── leaderboard.py # Работа с рейтингом +├── interface.py # GUI элементы +├── constants.py # Константы игры +├── help.py # Система помощи +├── leaderboard.json # Таблица лидеров (JSON) +├── lkon.png # Скриншот игры +├── pre-commit-config.yaml # Настройки pre-commit +├── mypy.ini # Конфигурация mypy +├── poetry.lock # Зависимости Poetry +├── pyproject.toml # Конфигурация проекта +└── README.md # Документация +##📊 Таблица лидеров + +Игра автоматически сохраняет результаты в JSON-файл leaderboard.json. +Для просмотра рейтинга используйте соответствующую кнопку в интерфейсе. + +## 👥 Авторы +Самородова Александра + +Назарова Инесса + +Checklist: +- [x] Добавлен `.gitignore`. Убедитесь, что там есть `.venv` и `.idea` +- [x] Создано виртуальное окружение +- [x] Есть файл `requirements.txt` или `pyproject.toml`. Исключения: если у вас нет внешних зависимостей. +- [x] Настроены линтеры: `mypy` и `flake8` +- [x] Настроены форматтеры: `isort` и `black` +- [x] Написаны тесты +- [x] Написана документация к каждому методу, классу и функции +- [x] Написан красивый `README.md` (для форматирования можно использовать markdown), где есть информация о том, как проект установить и запустить, что он делает и умеет, какие фунции там есть +- [x] (Для консольных утилит) написан help +- [x] (Опционально) Есть прекоммит diff --git a/dots/constants.py b/dots/constants.py new file mode 100644 index 0000000..af134f3 --- /dev/null +++ b/dots/constants.py @@ -0,0 +1,10 @@ +from typing import Final + + +CELL_SIZE: Final[int] = 20 +WIDTH: Final[int] = 1200 +HEIGHT: Final[int] = 750 +OFFSET_X: Final[int] = 20 +OFFSET_Y: Final[int] = 50 +BLUE_TEAM: Final[int] = 1 +RED_TEAM: Final[int] = 2 diff --git a/dots/dots_game.py b/dots/dots_game.py new file mode 100644 index 0000000..f1a4fb2 --- /dev/null +++ b/dots/dots_game.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +from enum import Enum +from tkinter import BOTH, Button, Canvas, Entry, Label, Tk, Toplevel, font, messagebox +from tkinter.ttk import Treeview +from typing import Any, Callable, List, Optional + +from constants import BLUE_TEAM, CELL_SIZE, OFFSET_X, OFFSET_Y, RED_TEAM +from game_logic import GameLogic +from interface import create_leaderboard, create_rules, create_scoreboard +from leaderboard import load_leaderboard, update_leaderboard + + +class GameResult(Enum): + """ + Перечисление возможных результатов игры. + """ + + BLUE_WIN = 1 + RED_WIN = 2 + DRAW = 3 + + @property + def winner_text(self) -> str: + texts = {GameResult.BLUE_WIN: "Синий игрок", GameResult.RED_WIN: "Красный игрок", GameResult.DRAW: "Ничья"} + return texts[self] + + @property + def color(self) -> str: + colors = {GameResult.BLUE_WIN: "darkblue", GameResult.RED_WIN: "darkred", GameResult.DRAW: "black"} + return colors[self] + + +def determine_result(blue_score: int, red_score: int) -> GameResult: + """ + Определяет результат игры на основе количества очков игроков. + """ + if blue_score > red_score: + return GameResult.BLUE_WIN + if red_score > blue_score: + return GameResult.RED_WIN + return GameResult.DRAW + + +class DotsGame: + def __init__(self, root: Tk) -> None: + """ + Инициализирует игру. + """ + self._root: Tk = root + self._canvas: Canvas = Canvas(root, bg="white") + self._canvas.pack(fill=BOTH, expand=True) + + self._grid_width: int = 30 + self._grid_height: int = 33 + self._mode: str = "PVP" + self._game_logic: Optional[GameLogic] = None + self._leaderboard: Optional[List[Any]] = None + self._leaderboard_tree: Optional[Treeview] = None + self._scoreboard: Optional[Treeview] = None + self._end_button: Optional[Button] = None + self._entry1: Optional[Entry] = None + self._entry2: Optional[Entry] = None + + def start(self) -> None: + """ + Запускает игру, инициализируя пользовательский интерфейс и игровое поле. + """ + self._setup_ui() + self._create_grid() + + def _setup_ui(self) -> None: + """ + Настраивает пользовательский интерфейс. + """ + self._bind_events() + self._init_interface_elements() + self._init_leaderboard_data() + + def _bind_events(self) -> None: + """ + Привязывает обработчики событий к элементам интерфейса. + """ + self._canvas.bind("", lambda e: self._create_grid()) + self._canvas.bind("", self._on_canvas_click) + + def _init_interface_elements(self) -> None: + """ + Инициализирует все элементы интерфейса. + """ + self._leaderboard_tree = create_leaderboard(self._root) + self._scoreboard = create_scoreboard(self._root) + create_rules(self._root) + self._create_mode_buttons() + self._create_size_selection() + self._create_end_game_button() + + def _init_leaderboard_data(self) -> None: + """ + Загружает данные таблицы лидеров. + """ + self._leaderboard = load_leaderboard() + self._update_leaderboard_display() + + def _update_leaderboard_display(self) -> None: + """ + Обновляет отображение таблицы лидеров. + """ + if self._leaderboard_tree is None or self._leaderboard is None: + return + + for row in self._leaderboard_tree.get_children(): + self._leaderboard_tree.delete(row) + for entry in self._leaderboard: + self._leaderboard_tree.insert("", "end", values=(entry.place, entry.name, entry.score)) + + def _create_end_game_button(self) -> None: + """ + Создает кнопку для досрочного завершения игры. + """ + self._end_button = Button( + self._root, + text="Завершить игру", + command=self._end_game, + fg="white", + bg="red", + font=("Arial", 11, "bold"), + width=17, + height=1, + ) + self._end_button.place(x=700, y=60) + + def _end_game(self) -> None: + """ + Завершает текущую игру. + """ + if self._game_logic is None: + return + + scores = self._game_logic.scores + blue_score = scores[BLUE_TEAM] + red_score = scores[RED_TEAM] + + result = determine_result(blue_score, red_score) + board_full = hasattr(self._game_logic, "is_board_full") and self._game_logic.is_board_full() + + message = ( + f"{'Доска заполнена' if board_full else 'Игра завершена досрочно'}! " f"Игра окончена! {result.winner_text}" + ) + winning_score_mapping = { + GameResult.BLUE_WIN: blue_score, + GameResult.RED_WIN: red_score, + GameResult.DRAW: max(blue_score, red_score), + } + winning_score = winning_score_mapping[result] + + if result != GameResult.DRAW: + self._show_winner_dialog(result, winning_score, message) + else: + self._show_game_over_message(message, result.color) + + def _show_winner_dialog(self, result: GameResult, winning_score: int, message: str) -> None: + """ + Показывает диалог победителя. + """ + name_window = Toplevel(master=self._root) + name_window.title("Поздравляем!") + name_window.geometry("400x200") + name_window.resizable(width=False, height=False) + + Label( + master=name_window, + text=f"Вы набрали {winning_score} очков!\nВведите ваше имя:", + font=("Arial", 12), + wraplength=380, + ).pack(pady=10) + + name_entry = Entry(master=name_window, font=("Arial", 12), width=30) + name_entry.pack(pady=5) + + def save_and_close() -> None: + player_name = name_entry.get().strip() + if player_name: + updated = update_leaderboard(player_name, winning_score) + if updated: + self._leaderboard = load_leaderboard() + self._update_leaderboard_display() + messagebox.showinfo("Успех", "Вы попали в таблицу лидеров!") + else: + messagebox.showinfo("Сожалеем", "Ваш результат не попал в топ-3.") + name_window.destroy() + self._show_game_over_message(message, result.color) + else: + messagebox.showerror("Ошибка", "Пожалуйста, введите имя!") + + Button(name_window, text="Сохранить", command=save_and_close, font=("Arial", 12)).pack(pady=10) + + def _show_game_over_message(self, message: str, color: str) -> None: + """ + Показывает окно с результатом игры. + """ + game_over_window = Toplevel(self._root) + game_over_window.title("Игра окончена") + game_over_window.geometry("400x150") + game_over_window.resizable(False, False) + + Label( + game_over_window, + text=message, + font=("Arial", 14, "bold"), + fg="white", + bg=color, + wraplength=380, + ).pack(expand=True, fill=BOTH) + + Button( + game_over_window, + text="OK", + command=game_over_window.destroy, + font=("Arial", 12), + ).pack(pady=10) + + self._game_logic = None + + def _create_grid(self) -> None: + """ + Отрисовывает игровое поле. + """ + self._canvas.delete("grid_line") + + start_x = OFFSET_X + start_y = OFFSET_Y + end_x = self._grid_width * CELL_SIZE + OFFSET_X + 1 + end_y = self._grid_height * CELL_SIZE + OFFSET_Y + 1 + + for coord in range(start_x, end_x, CELL_SIZE): + self._canvas.create_line(coord, start_y, coord, end_y, tags="grid_line", fill="lightblue") + for coord in range(start_y, end_y, CELL_SIZE): + self._canvas.create_line(start_x, coord, end_x, coord, tags="grid_line", fill="lightblue") + + def _on_canvas_click(self, event: Any) -> None: + """ + Обрабатывает клик по игровому полю. + """ + if self._game_logic is None: + return + + offset_x = (event.x - OFFSET_X) % CELL_SIZE + offset_y = (event.y - OFFSET_Y) % CELL_SIZE + + grid_x = (event.x - OFFSET_X) // CELL_SIZE + (1 if offset_x >= CELL_SIZE / 2 else 0) + grid_y = (event.y - OFFSET_Y) // CELL_SIZE + (1 if offset_y >= CELL_SIZE / 2 else 0) + + grid_x = max(0, min(grid_x, self._grid_width)) + grid_y = max(0, min(grid_y, self._grid_height)) + + if 0 <= grid_x <= self._grid_width and 0 <= grid_y <= self._grid_height: + current_player_before = self._game_logic.current_player + if not self._game_logic.is_valid_move(grid_x, grid_y): + return + + game_ended = self._game_logic.place_dot(grid_x, grid_y) + + x = grid_x * CELL_SIZE + OFFSET_X + y = grid_y * CELL_SIZE + OFFSET_Y + color = self._game_logic.player_colors[current_player_before] + self._draw_dot(x, y, color) + self._draw_lines() + + if self._game_logic.last_captured > 0: + self._show_capture_message(current_player_before, self._game_logic.last_captured) + + self.update_scoreboard() + + if game_ended: + self._end_game() + return + + self._game_logic.switch_player() + + if self._mode != "PVP" and self._game_logic.current_player == 2: + self._root.after(500, self._make_ai_move) + + def _make_ai_move(self) -> None: + """ + Выполняет ход компьютера. + """ + if self._game_logic is None: + return + if self._mode == "PVAIR": + move = self._game_logic.make_ai_move_random() + else: + move = self._game_logic.make_ai_move_smart() + + if move: + grid_x, grid_y = move + game_ended = self._game_logic.place_dot(grid_x, grid_y) + + x = grid_x * CELL_SIZE + OFFSET_X + y = grid_y * CELL_SIZE + OFFSET_Y + color = self._game_logic.player_colors[2] + self._draw_dot(x, y, color) + self._draw_lines() + + if self._game_logic.last_captured > 0: + self._show_capture_message(2, self._game_logic.last_captured) + + self.update_scoreboard() + + if game_ended: + self._end_game() + return + + self._game_logic.switch_player() + + def _draw_dot(self, x: int, y: int, color: str) -> None: + """ + Рисует точку на поле. + """ + self._canvas.create_oval(x - 4, y - 4, x + 4, y + 4, fill=color, outline=color) + + def _draw_lines(self) -> None: + """ + Рисует линии между точками. + """ + self._canvas.delete("lines") + + if self._game_logic is not None: + for line in self._game_logic.lines: + x1, y1, x2, y2, color = line + self._canvas.create_line([x1, y1, x2, y2], fill=color, width=2, tags="lines") + + def update_scoreboard(self) -> None: + """ + Обновляет счет на экране. + """ + if self._game_logic is None or self._scoreboard is None: + return + + if hasattr(self._game_logic, 'scores'): + for row in self._scoreboard.get_children(): + self._scoreboard.delete(row) + + self._scoreboard.insert( + "", + "end", + values=( + f"Синий: {self._game_logic.scores.get(1, 0)}", + f"Красный: {self._game_logic.scores.get(2, 0)}", + ), + tags=("blue", "red"), + ) + + self._scoreboard.tag_configure("blue", foreground="darkblue") + self._scoreboard.tag_configure("red", foreground="darkred") + + def _show_capture_message(self, player: int, count: int) -> None: + """ + Показывает сообщение о захвате точек. + """ + color = "синий" if player == 1 else "красный" + message = f"Игрок {player} ({color}) захватил {count} точек!" + + msg_label = Label( + self._root, + text=message, + font=("Arial", 12, "bold"), + fg="white", + bg="darkblue" if player == 1 else "darkred", + ) + msg_label.place(relx=0.5, rely=0.95, anchor="center") + + self._root.after(2000, msg_label.destroy) + + def _start_new_game(self, width: int, height: int, mode: str) -> None: + """ + Запускает новую игру. + """ + self._grid_width = width + self._grid_height = height + self._mode = mode + self._game_logic = GameLogic(width, height, self) + self._game_logic.player_colors = {1: "darkblue", 2: "darkred"} + + if not hasattr(self._game_logic, 'scores'): + self._game_logic.scores = {1: 0, 2: 0} + + self._canvas.delete("all") + self._create_grid() + self.update_scoreboard() + + def _create_mode_buttons(self) -> None: + """ + Создает кнопки выбора режима. + """ + header_label = Label( + self._root, + text="Выбор режима", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + header_label.place(relx=1.0, rely=0.0, x=-350, y=247, anchor="ne") + + buttons = [ + ("Игрок1 / Игрок2", 280, "PVP"), + ("Игрок1 / Компьютер рандом", 340, "PVAIR"), + ("Игрок1 / Компьютер умный", 400, "PVAIS"), + ] + + for text, y_pos, mode in buttons: + button = Button( + self._root, + text=text, + command=self._create_mode_handler(mode), + fg="black", + bg="white", + font=("Arial", 10), + width=25, + height=2, + ) + button.place(x=680, y=y_pos) + + def _create_mode_handler(self, mode: str) -> Callable[[], None]: + """ + Создает обработчик для кнопок режима. + """ + return lambda: self._start_new_game(self._grid_width, self._grid_height, mode) + + def _create_size_selection(self) -> None: + """ + Создает элементы выбора размера поля. + """ + header_label = Label( + self._root, + text="Выбор размера поля", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + header_label.place(relx=1.0, rely=0.0, x=-70, y=250, anchor="ne") + + info_label = Label( + self._root, + text="Максимальные wxh (30x33) клеток", + font=("Arial", 10), + bg="white", + highlightthickness=0, + ) + info_label.place(relx=1.0, rely=0.0, x=-50, y=270, anchor="ne") + + custom_font = font.Font(size=14) + self._entry1 = Entry(self._root, width=15, font=custom_font) + self._entry1.place(relx=1.0, rely=0.0, x=-70, y=310, anchor="ne") + + self._entry2 = Entry(self._root, width=15, font=custom_font) + self._entry2.place(relx=1.0, rely=0.0, x=-70, y=360, anchor="ne") + + button_select = Button( + self._root, + text="Выбрать", + command=self._update_grid_size, + fg="black", + bg="white", + font=("Arial", 10), + width=23, + height=1, + ) + button_select.place(x=950, y=420) + + def _update_grid_size(self) -> None: + """ + Обновляет размер поля. + """ + if self._entry1 is None or self._entry2 is None: + return + + try: + new_width = int(self._entry1.get()) + new_height = int(self._entry2.get()) + + if new_width > 30 or new_height > 33: + messagebox.showerror("Ошибка", "Размеры не должны превышать 30x33") + return + + self._grid_width = new_width + self._grid_height = new_height + self._start_new_game(new_width, new_height, self._mode) + except ValueError: + messagebox.showerror("Ошибка", "Пожалуйста, введите корректные числа") diff --git a/dots/game_logic.py b/dots/game_logic.py new file mode 100644 index 0000000..c0d2768 --- /dev/null +++ b/dots/game_logic.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from collections import deque +import random +from typing import Any + +from constants import CELL_SIZE, OFFSET_X, OFFSET_Y + + +class GameLogic: + """ + Класс для реализации логики игры, которая управляет игровым полем, движением + игроков, захватами и состоянием игры. + """ + + PLAYER_1 = 1 + PLAYER_2 = 2 + EMPTY_CELL = 0 + + FOUR_DIRECTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)] + EIGHT_DIRECTIONS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)] + + INITIAL_FILLED_CELLS = 0 + MAX_ATTEMPTS = 500 + + def __init__(self, width: int, height: int, game_ui: Any) -> None: + """Инициализация класса GameLogic.""" + self._grid_width: int = width + self._grid_height: int = height + self._game_ui: Any = game_ui + self.scores: dict[int, int] = { + self.PLAYER_1: self.INITIAL_FILLED_CELLS, + self.PLAYER_2: self.INITIAL_FILLED_CELLS, + } + self.player_colors: dict[int, str] = {self.PLAYER_1: "darkblue", self.PLAYER_2: "darkred"} + self._grid: list[list[int]] = [[self.EMPTY_CELL for _ in range(width + 1)] for _ in range(height + 1)] + self.current_player: int = self.PLAYER_1 + self.lines: list[tuple[int, int, int, int, str]] = [] + self.last_captured: int = self.INITIAL_FILLED_CELLS + self._total_cells: int = (width + 1) * (height + 1) + self._filled_cells: int = self.INITIAL_FILLED_CELLS + self._controlled_areas: dict[int, list[tuple[int, int]]] = {self.PLAYER_1: [], self.PLAYER_2: []} + + def _is_board_full(self) -> bool: + """Проверяет, заполнено ли игровое поле.""" + return self._filled_cells >= self._total_cells + + def is_valid_move(self, grid_x: int, grid_y: int) -> bool: + """Проверяет, является ли указанный ход допустимым.""" + if not (0 <= grid_x <= self._grid_width and 0 <= grid_y <= self._grid_height): + return False + return self._grid[grid_y][grid_x] == self.EMPTY_CELL + + def place_dot(self, grid_x: int, grid_y: int) -> bool: + """Помещает точку игрока на поле.""" + if not self._validate_move(grid_x, grid_y): + return False + + self._execute_move(grid_x, grid_y) + + if self._is_board_full(): + return True + + self._check_neighbors(grid_x, grid_y) + self._handle_captures(grid_x, grid_y) + self._check_self_capture(grid_x, grid_y) + + return self._is_board_full() + + def _validate_move(self, grid_x: int, grid_y: int) -> bool: + """Validate if move is possible.""" + if not (0 <= grid_x <= self._grid_width and 0 <= grid_y <= self._grid_height): + return False + return self._grid[grid_y][grid_x] == self.EMPTY_CELL + + def _execute_move(self, grid_x: int, grid_y: int) -> None: + """Execute the move on the grid.""" + self._grid[grid_y][grid_x] = self.current_player + self._filled_cells += 1 + + def _handle_captures(self, grid_x: int, grid_y: int) -> None: + """Handle opponent captures after move.""" + captures = self._check_captures(grid_x, grid_y) + if captures > self.INITIAL_FILLED_CELLS: + self.scores[self.current_player] += captures + self._game_ui.update_scoreboard() + + def _check_self_capture(self, grid_x: int, grid_y: int) -> None: + """Check if move causes self-capture.""" + opponent = self.PLAYER_2 if self.current_player == self.PLAYER_1 else self.PLAYER_1 + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = grid_x + dx, grid_y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.current_player: + group, is_surrounded = self._get_group_with_status(nx, ny, self.current_player) + if is_surrounded: + self_captures = sum(1 for gx, gy in group if self._grid[gy][gx] == self.current_player) + if self_captures > 0: + self.scores[opponent] += self_captures + self._game_ui.update_scoreboard() + + def _check_neighbors(self, grid_x: int, grid_y: int) -> None: + """ + Проверяет соседей текущей клетки и добавляет линии между соседними + клетками одного игрока. + """ + color = self.player_colors[self.current_player] + for dx, dy in self.EIGHT_DIRECTIONS: + nx, ny = grid_x + dx, grid_y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.current_player: + x1 = grid_x * CELL_SIZE + OFFSET_X + y1 = grid_y * CELL_SIZE + OFFSET_Y + x2 = nx * CELL_SIZE + OFFSET_X + y2 = ny * CELL_SIZE + OFFSET_Y + line = (x1, y1, x2, y2, color) + if line not in self.lines: + self.lines.append(line) + + def _check_captures(self, x: int, y: int) -> int: + """ + Проверяет захват окруженных групп противника. + Возвращает количество захваченных точек (без удаления их с поля). + """ + opponent = self.PLAYER_2 if self.current_player == self.PLAYER_1 else self.PLAYER_1 + captured = 0 + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == opponent: + group, is_surrounded = self._get_group_with_status(nx, ny, opponent) + if is_surrounded: + captured += sum(1 for gx, gy in group if self._grid[gy][gx] == opponent) + + self.last_captured = captured + return captured + + def _get_group_with_status(self, x: int, y: int, player: int) -> tuple[list[tuple[int, int]], bool]: + """ + Получает группу точек игрока и пустых клеток, начиная с координат (x, y), + и определяет, окружена ли она точками противника или границей поля. + """ + visited = [[False for _ in range(self._grid_width + 1)] for _ in range(self._grid_height + 1)] + queue = deque([(x, y)]) + visited[y][x] = True + group = [] + is_surrounded = True + opponent = self.PLAYER_2 if player == self.PLAYER_1 else self.PLAYER_1 + + while queue: + cx, cy = queue.popleft() + group.append((cx, cy)) + + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = cx + dx, cy + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if not visited[ny][nx]: + if self._grid[ny][nx] == player or self._grid[ny][nx] == self.EMPTY_CELL: + visited[ny][nx] = True + queue.append((nx, ny)) + else: + is_surrounded = False + for cx, cy in group: + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = cx + dx, cy + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if (nx, ny) not in group and self._grid[ny][nx] != opponent: + is_surrounded = False + break + if not is_surrounded: + break + + return group, is_surrounded + + def switch_player(self) -> None: + """Меняет текущего игрока.""" + self.current_player = self.PLAYER_2 if self.current_player == self.PLAYER_1 else self.PLAYER_1 + + def make_ai_move_random(self) -> tuple[int, int] | None: + """Выполняет случайный ход для ИИ.""" + attempts = 0 + while attempts < self.MAX_ATTEMPTS: + grid_x = random.randint(0, self._grid_width) + grid_y = random.randint(0, self._grid_height) + if self.is_valid_move(grid_x, grid_y): + if self.place_dot(grid_x, grid_y): + return None + return grid_x, grid_y + attempts += 1 + return None + + def make_ai_move_smart(self) -> tuple[int, int] | None: + """Smart AI move: tries to capture player dots first, then plays strategically.""" + capture_move = self._find_capture_move() + if capture_move: + return self._execute_ai_move(capture_move[0], capture_move[1]) + + strategic_move = self._find_strategic_move() + if strategic_move: + return self._execute_ai_move(strategic_move[0], strategic_move[1]) + + return self.make_ai_move_random() + + def _find_capture_move(self) -> tuple[int, int] | None: + """Find a move that would capture player dots.""" + for y in range(self._grid_height + 1): + for x in range(self._grid_width + 1): + if self.is_valid_move(x, y) and self._would_capture_player(x, y): + return x, y + return None + + def _find_strategic_move(self) -> tuple[int, int] | None: + """Find a strategic move near player dots.""" + player_dots = self._get_player_dots() + if not player_dots: + return None + + candidate_moves = self._get_candidate_moves(player_dots) + if not candidate_moves: + return None + + return self._select_best_move(candidate_moves) + + def _get_player_dots(self) -> list[tuple[int, int]]: + """Get all player dots on the board.""" + dots = [] + for y in range(self._grid_height + 1): + for x in range(self._grid_width + 1): + if self._grid[y][x] == self.PLAYER_1: + dots.append((x, y)) + return dots + + def _get_candidate_moves(self, player_dots: list[tuple[int, int]]) -> list[tuple[int, int]]: + """Get all valid moves near player dots.""" + candidates = [] + for x, y in player_dots: + for dx, dy in self.EIGHT_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self.is_valid_move(nx, ny): + candidates.append((nx, ny)) + return candidates + + def _select_best_move(self, candidates: list[tuple[int, int]]) -> tuple[int, int]: + """Select best move from candidates based on scoring. If no candidates, return a random move.""" + if not candidates: + return self.make_ai_move_random() or (0, 0) + + best_move = candidates[0] + best_score = -1 + + for x, y in candidates: + score = self._calculate_move_score(x, y) + if score > best_score or (score == best_score and random.random() < 0.5): + best_score = score + best_move = (x, y) + return best_move + + def _calculate_move_score(self, x: int, y: int) -> int: + """Calculate score for a potential move.""" + score = 0 + for dx, dy in self.EIGHT_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.PLAYER_1: + score += 3 + elif self._grid[ny][nx] == self.PLAYER_2: + score += 1 + return score + + def _execute_ai_move(self, x: int, y: int) -> tuple[int, int] | None: + """Execute the AI move and return coordinates or None if game ends.""" + if self.place_dot(x, y): + return None + return x, y + + def _would_capture_player(self, x: int, y: int) -> bool: + """Проверяет, приведет ли ход к захвату области игрока.""" + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.PLAYER_1: + group, is_surrounded = self._get_group_with_status(nx, ny, self.PLAYER_1) + if is_surrounded: + return True + return False diff --git a/dots/help.py b/dots/help.py new file mode 100644 index 0000000..e452d0a --- /dev/null +++ b/dots/help.py @@ -0,0 +1,45 @@ +import argparse +import textwrap + + +def print_help() -> None: + """Выводит справочную информацию о игре.""" + help_text = """ + Игра 'Точки' - это стратегическая игра для двух игроков. + + Основные правила: + - Игроки по очереди ставят точки своего цвета на поле + - При соединении точек образуются линии + - Захваченные области подсчитываются как очки + - Побеждает игрок с наибольшим количеством очков + + Управление: + - ЛКМ - поставить точку + - Кнопки в правой панели - выбор режима игры + - Можно выбрать размер поля (до 30x33) + - Есть три режима игры: + * PVP - игрок против игрока + * PVAIR - игрок против ИИ (случайные ходы) + * PVAIS - игрок против умного ИИ + + Таблица лидеров: + - Сохраняются лучшие результаты + - Топ-3 игроков отображается в правой панели + """ + print(textwrap.dedent(help_text).strip()) + + +def main() -> None: + parser = argparse.ArgumentParser(description='Справка по игре "Точки"') + parser.add_argument('command', nargs='?', default='help', help='Команда справки') + args = parser.parse_args() + + if args.command == 'help': + print_help() + else: + print(f"Неизвестная команда: {args.command}") + print("Доступные команды: help") + + +if __name__ == "__main__": + main() diff --git a/dots/ikon.png b/dots/ikon.png new file mode 100644 index 0000000..8d0148a Binary files /dev/null and b/dots/ikon.png differ diff --git a/dots/interface.py b/dots/interface.py new file mode 100644 index 0000000..441851b --- /dev/null +++ b/dots/interface.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from tkinter import END, VERTICAL, WORD, Label, Scrollbar, Text, Tk, ttk +from tkinter.ttk import Treeview + + +def create_leaderboard(root: Tk) -> Treeview: + """ + Создает таблицу лидеров в GUI, отображающую рейтинг игроков. + """ + _columns: tuple[str, str, str] = ("Место", "Имя", "Счет") + + _header_label: Label = Label( + root, + text="Таблица лидеров", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + _header_label.place(relx=1.0, rely=0.0, x=-65, y=20, anchor="ne") + + _tree: Treeview = ttk.Treeview(root, columns=_columns, show="headings") + for col in _columns: + _tree.heading(col, text=col) + _tree.column(col, width=80, anchor="center") + + _style: ttk.Style = ttk.Style() + _style.configure("Treeview", rowheight=40, font=("Arial", 10)) + _style.configure("Treeview.Heading", font=("Arial", 10, "bold"), background="lightblue") + + _num_rows: int = 3 + _row_height: int = 45 + _header_height: int = 30 + _total_height: int = _num_rows * _row_height + _header_height + + _tree.place(relx=1.0, rely=0.0, x=-20, y=50, anchor="ne", height=_total_height) + + return _tree + + +def create_scoreboard(root: Tk) -> Treeview: + """ + Создает таблицу счета в GUI, отображающую текущие очки обоих игроков. + """ + _columns: tuple[str, str] = ("Игрок1 (Синий)", "Игрок2 (Красный)") + + _header_label: Label = Label(root, text="Счет", font=("Arial", 12, "bold"), bg="white", highlightthickness=0) + _header_label.place(relx=1.0, rely=0.0, x=-400, y=110, anchor="ne") + + _tree: Treeview = ttk.Treeview(root, columns=_columns, show="headings") + for col in _columns: + _tree.heading(col, text=col) + _tree.column(col, width=120, anchor="center") + + _tree.insert("", END, values=("Синий: 0", "Красный: 0"), tags=("blue", "red")) + + _tree.tag_configure("blue", foreground="darkblue") + _tree.tag_configure("red", foreground="darkred") + + _num_rows: int = 1 + _row_height: int = 45 + _header_height: int = 30 + _total_height: int = _num_rows * _row_height + _header_height + + _tree.place(relx=1.0, rely=0.0, x=-300, y=140, anchor="ne", height=_total_height) + + return _tree + + +def create_rules(root: Tk) -> None: + """ + Создает и отображает раздел с правилами игры в GUI. + """ + _header_label: Label = Label( + root, + text="Правила игры", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + _header_label.place(relx=1.0, rely=0.0, x=-210, y=520, anchor="ne") + + _text_block: Text = Text(root, wrap=WORD, width=70, height=10, font=("Arial", 10)) + _text_block.insert( + END, + "Ход: игроки ходят поочерёдно, за один ход можно поставить только одну точку. Разрешено " + "рисовать точки только на пересечении линий, где нет других знаков — в свободном поле.\n" + "Цель: окружить точки соперника. Окружение — это создание на определённом участке игрового поля " + "области, замкнутой внутри непрерывной непересекающейся цепи точек одного цвета, отстоящих друг " + "от друга не более, чем на одну клетку по горизонтали, вертикали или диагонали.\n" + "Захваченные точки: если в области, окружённой точками одного цвета, есть точки другого цвета, " + "то точки соперника внутри неё выключаются из дальнейшей игры, считаясь захваченными, и идут в " + "зачёт окружившему.\n" + "Домик: если замыкается область, внутри которой нет точек соперника, она называется «домиком» и " + "не является окружением. Такая область не закрашивается, а в пустые пункты внутри неё можно ставить " + "точки.\n" + "Подсчёт очков: по окончании игры производится подсчёт окружённых точек. Победителем считается " + "игрок, захвативший хотя бы на одну точку больше, чем соперник. При равенстве по этому показателю " + "объявляется ничья.", + ) + _text_block.configure(state="disabled") + _text_block.place(relx=0.0, rely=0.0, x=670, y=550, anchor="nw") + + _scrollbar: Scrollbar = Scrollbar(root, orient=VERTICAL, command=_text_block.yview) + _scrollbar.place(relx=0.0, rely=0.0, x=1160, y=550, anchor="nw", height=160) + _text_block.config(yscrollcommand=_scrollbar.set) diff --git a/dots/leaderboard.py b/dots/leaderboard.py new file mode 100644 index 0000000..5308611 --- /dev/null +++ b/dots/leaderboard.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from pydantic import BaseModel + + +_LEADERBOARD_SIZE = 3 +_DEFAULT_NAME = "None" +_DEFAULT_SCORE = 0 + + +class LeaderboardEntry(BaseModel): + """ + Модель записи в таблице лидеров. + """ + + place: int + name: str + score: int + + +def load_leaderboard() -> list[LeaderboardEntry]: + """ + Загружает таблицу лидеров из файла JSON. + """ + _leaderboard_file = Path("leaderboard.json") + + if not _leaderboard_file.exists(): + return [ + LeaderboardEntry(place=i + 1, name=_DEFAULT_NAME, score=_DEFAULT_SCORE) for i in range(_LEADERBOARD_SIZE) + ] + + with _leaderboard_file.open("r", encoding="utf-8") as _f: + _data = json.load(_f) + return [LeaderboardEntry(**entry) for entry in _data] + + +def save_leaderboard(leaderboard: list[LeaderboardEntry]) -> None: + """ + Сохраняет таблицу лидеров в файл JSON. + """ + _leaderboard_file = Path("leaderboard.json") + with _leaderboard_file.open("w", encoding="utf-8") as _f: + json.dump([entry.model_dump() for entry in leaderboard], _f, indent=4, ensure_ascii=False) + + +def update_leaderboard(new_name: str, new_score: int) -> bool: + """ + Обновляет таблицу лидеров новым результатом. + """ + _leaderboard = load_leaderboard() + + if new_score <= _leaderboard[-1].score and _leaderboard[-1].name != _DEFAULT_NAME: + return False + + _leaderboard.append(LeaderboardEntry(place=0, name=new_name, score=new_score)) + _leaderboard.sort(key=lambda x: x.score if x.name != _DEFAULT_NAME else -1, reverse=True) + _leaderboard = _leaderboard[:_LEADERBOARD_SIZE] + + for i, entry in enumerate(_leaderboard, 1): + entry.place = i + + save_leaderboard(_leaderboard) + return True diff --git a/dots/main.py b/dots/main.py new file mode 100644 index 0000000..d64e431 --- /dev/null +++ b/dots/main.py @@ -0,0 +1,27 @@ +import sys +from tkinter import PhotoImage, Tk + +from constants import HEIGHT, WIDTH +from dots_game import DotsGame + + +def main() -> None: + if len(sys.argv) > 1 and sys.argv[1] == 'help': + from help import print_help + + print_help() + return + + root: Tk = Tk() + root.title("Dots Game") + icon: PhotoImage = PhotoImage(file="ikon.png") + root.iconphoto(True, icon) + root.geometry(f"{WIDTH}x{HEIGHT}") + root.resizable(width=False, height=False) + game: DotsGame = DotsGame(root) + game.start() + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/dots/test_dots_game.py b/dots/test_dots_game.py new file mode 100644 index 0000000..c421eb1 --- /dev/null +++ b/dots/test_dots_game.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from tkinter import Canvas, Tk +from unittest.mock import MagicMock, patch + +from constants import CELL_SIZE, OFFSET_X, OFFSET_Y +from dots_game import DotsGame, GameResult, determine_result +from game_logic import GameLogic +import pytest + + +@pytest.fixture +def root() -> Tk: + """Фикстура: создает корневое окно Tkinter для тестирования.""" + return Tk() + + +@pytest.fixture +def game(root: Tk) -> DotsGame: + """Фикстура: создает экземпляр игры DotsGame для тестирования.""" + return DotsGame(root) + + +@pytest.fixture +def mock_game_logic() -> MagicMock: + """Фикстура: создает mock-объект для имитации GameLogic.""" + return MagicMock(spec=GameLogic) + + +def test_game_result_blue_win() -> None: + """Тест проверяет корректность значений для результата 'победа синего игрока'.""" + result = GameResult.BLUE_WIN + assert result.winner_text == "Синий игрок" + assert result.color == "darkblue" + + +def test_game_result_red_win() -> None: + """Тест проверяет корректность значений для результата 'победа красного игрока'.""" + result = GameResult.RED_WIN + assert result.winner_text == "Красный игрок" + assert result.color == "darkred" + + +def test_game_result_draw() -> None: + """Тест проверяет корректность значений для результата 'ничья'.""" + result = GameResult.DRAW + assert result.winner_text == "Ничья" + assert result.color == "black" + + +def test_determine_result_blue_win() -> None: + """Тест проверяет функцию определения результата при победе синего игрока.""" + assert determine_result(5, 3) == GameResult.BLUE_WIN + + +def test_determine_result_red_win() -> None: + """Тест проверяет функцию определения результата при победе красного игрока.""" + assert determine_result(3, 5) == GameResult.RED_WIN + + +def test_determine_result_draw() -> None: + """Тест проверяет функцию определения результата при ничьей.""" + assert determine_result(4, 4) == GameResult.DRAW + + +def test_game_initialization(game: DotsGame) -> None: + """Тест проверяет корректность инициализации игры (значения по умолчанию).""" + assert game._grid_width == 30 + assert game._grid_height == 33 + assert game._mode == "PVP" + assert game._game_logic is None + + +def test_start_game(game: DotsGame) -> None: + """Тест проверяет корректность запуска игры (создание canvas и привязка событий).""" + game.start() + assert isinstance(game._canvas, Canvas) + assert game._canvas.bind("") is not None + assert game._canvas.bind("") is not None + + +@patch('dots_game.GameLogic') +def test_start_new_game_pvp(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Тест проверяет создание новой игры в режиме PVP.""" + game._start_new_game(10, 10, "PVP") + assert game._grid_width == 10 + assert game._grid_height == 10 + assert game._mode == "PVP" + mock_game_logic.assert_called_once_with(10, 10, game) + + +@patch('dots_game.GameLogic') +def test_start_new_game_pvair(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Тест проверяет создание новой игры в режиме PVAIR (против ИИ).""" + game._start_new_game(15, 15, "PVAIR") + assert game._mode == "PVAIR" + mock_game_logic.assert_called_once_with(15, 15, game) + + +def test_create_grid(game: DotsGame) -> None: + """Тест проверяет корректность создания сетки на canvas.""" + game._grid_width = 5 + game._grid_height = 5 + game._create_grid() + canvas_items = game._canvas.find_all() + assert len(canvas_items) == (5 + 1) * 2 + + +def test_draw_dot(game: DotsGame) -> None: + """Тест проверяет корректность отрисовки точки на canvas.""" + game._draw_dot(50, 50, "red") + canvas_items = game._canvas.find_all() + assert len(canvas_items) == 1 + assert game._canvas.itemcget(canvas_items[0], "fill") == "red" + + +def test_draw_lines(game: DotsGame, mock_game_logic: MagicMock) -> None: + """Тест проверяет корректность отрисовки линий на canvas.""" + game._game_logic = mock_game_logic + mock_game_logic.lines = [(10, 10, 20, 20, "blue")] + game._draw_lines() + canvas_items = game._canvas.find_withtag("lines") + assert len(canvas_items) == 1 + + +def test_update_scoreboard(game: DotsGame, mock_game_logic: MagicMock) -> None: + """Тест проверяет корректность обновления отображения счета.""" + game._game_logic = mock_game_logic + mock_game_logic.scores = {1: 5, 2: 3} + game._scoreboard = MagicMock() + + game.update_scoreboard() + assert game._scoreboard is not None + + +def test_show_capture_message(game: DotsGame) -> None: + """Тест проверяет, что сообщение о захвате отображается на заданное время.""" + with patch.object(game._root, 'after') as mock_after: + game._show_capture_message(1, 3) + args, kwargs = mock_after.call_args + assert args[0] == 2000 + assert callable(args[1]) + + +@patch('dots_game.GameLogic') +def test_on_canvas_click_with_game_logic(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Test canvas click handling with game logic.""" + # Create a proper mock for game logic + mock_logic = MagicMock() + mock_logic.place_dot.return_value = False + mock_logic.current_player = 1 + mock_logic.player_colors = {1: "blue"} + mock_logic.last_captured = 0 + mock_logic.scores = {1: 0, 2: 0} + mock_logic.lines = [] + + game._game_logic = mock_logic + game._canvas = MagicMock() + game._canvas.find_all.return_value = [1] + + event = MagicMock() + event.x = OFFSET_X + CELL_SIZE + event.y = OFFSET_Y + CELL_SIZE + + game._on_canvas_click(event) + + game._canvas.create_oval.assert_called_once() + + +@patch('dots_game.GameLogic') +def test_on_canvas_click_game_ended(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Test canvas click handling when game ends.""" + # Create a proper mock for game logic + mock_logic = MagicMock() + mock_logic.place_dot.return_value = True + mock_logic.current_player = 1 + mock_logic.player_colors = {1: "blue"} + mock_logic.last_captured = 1 + mock_logic.scores = {1: 0, 2: 0} + mock_logic.lines = [] + mock_logic.is_game_over = True + + game._game_logic = mock_logic + game._canvas = MagicMock() + + with patch.object(game, '_end_game') as mock_end_game: + event = MagicMock() + event.x = OFFSET_X + CELL_SIZE + event.y = OFFSET_Y + CELL_SIZE + game._on_canvas_click(event) + mock_end_game.assert_called_once() + + +def test_on_canvas_click_no_game_logic(game: DotsGame) -> None: + """Тест проверяет обработку клика по canvas при отсутствии игровой логики.""" + event = MagicMock() + event.x = 100 + event.y = 100 + game._on_canvas_click(event) + assert game._game_logic is None + + +def test_make_ai_move_random(game: DotsGame, mock_game_logic: MagicMock) -> None: + """ + Тест проверяет, что в режиме "PVAIR" вызывается метод make_ai_move_random + объекта GameLogic. + """ + game._mode = "PVAIR" + game._game_logic = mock_game_logic + mock_game_logic.make_ai_move_random.return_value = (5, 5) + mock_game_logic.player_colors = {1: "blue", 2: "red"} + mock_game_logic.lines = [] + mock_game_logic.last_captured = 0 + mock_game_logic.scores = {1: 0, 2: 0} + game._make_ai_move() + mock_game_logic.make_ai_move_random.assert_called_once() + + +def test_make_ai_move_smart(game: DotsGame, mock_game_logic: MagicMock) -> None: + """ + Тест проверяет, что в режиме "PVAIS" вызывается метод make_ai_move_smart + объекта GameLogic. + """ + game._mode = "PVAIS" + game._game_logic = mock_game_logic + mock_game_logic.make_ai_move_smart.return_value = (5, 5) + game._game_logic.player_colors = {1: "blue", 2: "red"} + game._game_logic.lines = [] + game._game_logic.last_captured = 0 + mock_game_logic.scores = {1: 0, 2: 0} + game._make_ai_move() + mock_game_logic.make_ai_move_smart.assert_called_once() + + +def test_end_game_no_logic(game: DotsGame) -> None: + """Тест проверяет, что при отсутствии игровой логики _game_logic становится None.""" + game._end_game() + assert game._game_logic is None + + +@patch('dots_game.messagebox') +def test_end_game_with_logic(mock_messagebox: MagicMock, game: DotsGame, mock_game_logic: MagicMock) -> None: + """ + Тест проверяет поведение _end_game при наличии игровой логики и завершенной игре. + Ожидается вызов _show_winner_dialog и отсутствие вызова messagebox. + """ + game._game_logic = mock_game_logic + mock_game_logic.scores = {1: 5, 2: 3} + mock_game_logic.is_game_over = True + with patch.object(game, '_show_winner_dialog') as mock_show_winner: + game._end_game() + mock_show_winner.assert_called_once() + mock_messagebox.assert_not_called() + + +def test_create_mode_buttons(game: DotsGame) -> None: + """Тест проверяет создание кнопок выбора режима игры.""" + game._create_mode_buttons() + assert len(game._root.winfo_children()) > 3 + + +def test_create_size_selection(game: DotsGame) -> None: + """Тест проверяет создание элементов выбора размера игрового поля.""" + game._create_size_selection() + assert game._entry1 is not None + assert game._entry2 is not None + + +@patch('dots_game.messagebox') +def test_update_grid_size_invalid(mock_messagebox: MagicMock, game: DotsGame) -> None: + """ + Тест проверяет обработку некорректного ввода размера игрового поля. + Ожидается отображение сообщения об ошибке. + """ + game._entry1 = MagicMock() + game._entry1.get.return_value = "abc" + game._entry2 = MagicMock() + game._entry2.get.return_value = "10" + + game._update_grid_size() + mock_messagebox.showerror.assert_called_once() + + +@patch('dots_game.messagebox') +def test_update_grid_size_too_large(mock_messagebox: MagicMock, game: DotsGame) -> None: + """ + Тест проверяет обработку ввода слишком большого размера игрового поля. + Ожидается отображение сообщения об ошибке. + """ + game._entry1 = MagicMock() + game._entry1.get.return_value = "40" + game._entry2 = MagicMock() + game._entry2.get.return_value = "40" + + game._update_grid_size() + mock_messagebox.showerror.assert_called_once() + + +def test_show_winner_dialog(game: DotsGame) -> None: + """Тест проверяет создание и вызов диалогового окна с сообщением о победителе.""" + with patch('dots_game.Toplevel') as mock_toplevel: + game._show_winner_dialog(GameResult.BLUE_WIN, 5, "Test message") + mock_toplevel.assert_called_once() + + +def test_create_end_game_button(game: DotsGame) -> None: + """Тест проверяет создание кнопки завершения игры с корректным текстом.""" + game._create_end_game_button() + assert game._end_button is not None + assert "Завершить игру" in game._end_button.cget("text") + + +def test_bind_events(game: DotsGame) -> None: + """Тест проверяет привязку событий к canvas (изменение размера и клик).""" + game._bind_events() + assert game._canvas.bind("") is not None + assert game._canvas.bind("") is not None + + +def test_init_interface_elements(game: DotsGame) -> None: + """Тест проверяет инициализацию основных элементов интерфейса (таблица лидеров, счет).""" + game._init_interface_elements() + assert game._leaderboard_tree is not None + assert game._scoreboard is not None + + +@patch('dots_game.load_leaderboard') +def test_init_leaderboard_data(mock_load: MagicMock, game: DotsGame) -> None: + """Тест проверяет загрузку данных для таблицы лидеров при инициализации.""" + mock_load.return_value = [MagicMock(place=1, name="Test", score=10)] + game._init_leaderboard_data() + assert game._leaderboard is not None + mock_load.assert_called_once() + + +def test_update_leaderboard_display(game: DotsGame) -> None: + """Тест проверяет обновление отображения таблицы лидеров.""" + game._leaderboard_tree = MagicMock() + game._leaderboard = [MagicMock(place=1, name="Test", score=10)] + game._update_leaderboard_display() + game._leaderboard_tree.insert.assert_called_once() + + +def test_update_grid_size_valid(game: DotsGame) -> None: + """ + Тест проверяет обновление размеров игрового поля при корректном вводе + и запуск новой игры с заданными размерами. + """ + game._entry1 = MagicMock() + game._entry1.get.return_value = "10" + game._entry2 = MagicMock() + game._entry2.get.return_value = "12" + + with patch.object(game, '_start_new_game') as mock_start_new: + game._update_grid_size() + + mock_start_new.assert_called_once_with(10, 12, game._mode) + assert game._grid_width == 10 + assert game._grid_height == 12 diff --git a/dots/test_game_logic.py b/dots/test_game_logic.py new file mode 100644 index 0000000..50d782a --- /dev/null +++ b/dots/test_game_logic.py @@ -0,0 +1,227 @@ +from unittest.mock import MagicMock + +from dots_game import GameLogic +import pytest + + +@pytest.fixture +def game_logic() -> GameLogic: + """Фикстура для создания экземпляра GameLogic с моком UI""" + ui_mock = MagicMock() + return GameLogic(width=5, height=5, game_ui=ui_mock) + + +def test_initialization(game_logic: GameLogic) -> None: + """Тест инициализации GameLogic""" + assert game_logic._grid_width == 5 + assert game_logic._grid_height == 5 + assert game_logic.current_player == GameLogic.PLAYER_1 + assert game_logic.scores == {1: 0, 2: 0} + assert len(game_logic._grid) == 6 + assert len(game_logic._grid[0]) == 6 + + +def test_is_board_full(game_logic: GameLogic) -> None: + """Тест проверки заполненности поля""" + assert not game_logic._is_board_full() + + for y in range(6): + for x in range(6): + game_logic._grid[y][x] = GameLogic.PLAYER_1 + game_logic._filled_cells = 36 + + assert game_logic._is_board_full() + + +def test_is_valid_move(game_logic: GameLogic) -> None: + """Тест проверки валидности хода""" + assert game_logic.is_valid_move(0, 0) + assert game_logic.is_valid_move(5, 5) + + assert not game_logic.is_valid_move(-1, 0) + assert not game_logic.is_valid_move(6, 5) + + game_logic._grid[2][3] = GameLogic.PLAYER_1 + assert not game_logic.is_valid_move(3, 2) + + +def test_place_dot_invalid_move(game_logic: GameLogic) -> None: + """Тест размещения точки с невалидным ходом""" + result = game_logic.place_dot(-1, 0) + assert not result + assert game_logic._filled_cells == 0 + + +def test_place_dot_valid_move(game_logic: GameLogic) -> None: + """Тест размещения точки с валидным ходом""" + result = game_logic.place_dot(2, 3) + assert result is False + assert game_logic._grid[3][2] == GameLogic.PLAYER_1 + assert game_logic._filled_cells == 1 + + +def test_place_dot_board_full(game_logic: GameLogic) -> None: + """Тест заполнения всего поля""" + for y in range(6): + for x in range(6): + if x != 3 or y != 3: + game_logic._grid[y][x] = GameLogic.PLAYER_1 + game_logic._filled_cells += 1 + + result = game_logic.place_dot(3, 3) + assert result is True + assert game_logic._is_board_full() + + +def test_check_neighbors(game_logic: GameLogic) -> None: + """Тест проверки соседей и создания линий""" + game_logic._grid[2][2] = GameLogic.PLAYER_1 + game_logic._check_neighbors(3, 3) + + assert len(game_logic.lines) == 1 + line = game_logic.lines[0] + assert line[4] == game_logic.player_colors[GameLogic.PLAYER_1] + + +def test_check_captures_no_capture(game_logic: GameLogic) -> None: + """Тест проверки захвата без захвата""" + game_logic._grid[2][3] = GameLogic.PLAYER_2 + captures = game_logic._check_captures(3, 3) + assert captures == 0 + + +def test_get_group_with_status(game_logic: GameLogic) -> None: + """Тест получения группы и статуса окружения""" + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + + group, surrounded = game_logic._get_group_with_status(1, 1, GameLogic.PLAYER_1) + assert len(group) >= 2 + assert not surrounded + + +def test_switch_player(game_logic: GameLogic) -> None: + """Тест смены игрока""" + assert game_logic.current_player == GameLogic.PLAYER_1 + game_logic.switch_player() + assert game_logic.current_player == GameLogic.PLAYER_2 + game_logic.switch_player() + assert game_logic.current_player == GameLogic.PLAYER_1 + + +def test_make_ai_move_random_failure(game_logic: GameLogic) -> None: + """Тест случайного хода ИИ (неудачный случай)""" + for y in range(6): + for x in range(6): + game_logic._grid[y][x] = GameLogic.PLAYER_1 + + move = game_logic.make_ai_move_random() + assert move is None + + +def test_make_ai_move_smart_capture(game_logic: GameLogic) -> None: + """Тест умного хода ИИ с захватом""" + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + game_logic._grid[2][1] = GameLogic.PLAYER_1 + + move = game_logic.make_ai_move_smart() + assert move == (2, 2) + + +def test_make_ai_move_smart_no_capture(game_logic: GameLogic) -> None: + """Тест умного хода ИИ без возможности захвата""" + move = game_logic.make_ai_move_smart() + assert move is not None + + +def test_would_capture_player_false(game_logic: GameLogic) -> None: + """Тест проверки потенциального захвата (False)""" + assert not game_logic._would_capture_player(0, 0) + + +def test_place_dot_with_self_capture(game_logic: GameLogic) -> None: + """Тест размещения точки с самозахватом""" + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + game_logic._grid[2][1] = GameLogic.PLAYER_2 + game_logic._grid[2][3] = GameLogic.PLAYER_2 + game_logic._grid[3][2] = GameLogic.PLAYER_2 + + game_logic.place_dot(2, 2) + + assert game_logic.scores[GameLogic.PLAYER_2] == 0 + + +def test_edge_case_max_board_size() -> None: + """Тест большого размера поля""" + ui_mock = MagicMock() + game = GameLogic(width=30, height=30, game_ui=ui_mock) + + assert game.is_valid_move(30, 30) + assert not game.is_valid_move(31, 30) + + +def test_controlled_areas_after_capture(game_logic: GameLogic) -> None: + """Тест обновления контролируемых областей после захвата""" + game_logic._grid[1][1] = GameLogic.PLAYER_2 + game_logic._grid[1][2] = GameLogic.PLAYER_2 + game_logic._grid[2][1] = GameLogic.PLAYER_1 + game_logic._grid[2][3] = GameLogic.PLAYER_1 + game_logic._grid[3][2] = GameLogic.PLAYER_1 + + game_logic.place_dot(2, 2) + + assert game_logic.scores[GameLogic.PLAYER_1] >= 0 + + +def test_ai_smart_move_prefers_capture(game_logic: GameLogic) -> None: + """Тест, что умный ИИ предпочитает ходы с захватом""" + game_logic.current_player = GameLogic.PLAYER_2 + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + game_logic._grid[2][1] = GameLogic.PLAYER_2 + game_logic._grid[2][3] = GameLogic.PLAYER_2 + game_logic._grid[3][2] = GameLogic.PLAYER_2 + + game_logic._grid[4][4] = GameLogic.PLAYER_2 + game_logic._grid[4][5] = GameLogic.PLAYER_2 + + move = game_logic.make_ai_move_smart() + assert move == (2, 2) + + +def test_would_capture_player_true(game_logic: GameLogic) -> None: + """Тест проверки потенциального захвата (True) - окончательная версия""" + game_logic.current_player = GameLogic.PLAYER_2 + game_logic._grid[1][1] = GameLogic.PLAYER_1 + + game_logic._grid[0][1] = GameLogic.PLAYER_2 + game_logic._grid[2][1] = GameLogic.PLAYER_2 + game_logic._grid[1][0] = GameLogic.PLAYER_2 + game_logic._grid[1][2] = GameLogic.PLAYER_2 + assert game_logic._would_capture_player(1, 2) + assert game_logic._would_capture_player(0, 1) + + +def test_edge_cases_for_group_detection(game_logic: GameLogic) -> None: + """Тест крайних случаев для обнаружения групп""" + # Проверяем группы на краях поля + game_logic._grid[0][0] = GameLogic.PLAYER_1 + game_logic._grid[0][1] = GameLogic.PLAYER_1 + game_logic._grid[1][0] = GameLogic.PLAYER_1 + + group, surrounded = game_logic._get_group_with_status(0, 0, GameLogic.PLAYER_1) + assert (0, 0) in group + assert (0, 1) in group + assert (1, 0) in group + assert not surrounded + game_logic._grid[4][4] = GameLogic.PLAYER_2 + game_logic._grid[4][5] = GameLogic.PLAYER_2 + game_logic._grid[5][4] = GameLogic.PLAYER_2 + + group, surrounded = game_logic._get_group_with_status(4, 4, GameLogic.PLAYER_2) + assert (4, 4) in group + assert (4, 5) in group + assert (5, 4) in group + assert not surrounded diff --git a/dots/test_leaderboard.py b/dots/test_leaderboard.py new file mode 100644 index 0000000..4d36c73 --- /dev/null +++ b/dots/test_leaderboard.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from leaderboard import ( + _DEFAULT_NAME, + _DEFAULT_SCORE, + _LEADERBOARD_SIZE, + LeaderboardEntry, + load_leaderboard, + save_leaderboard, + update_leaderboard, +) +import pytest + + +@pytest.fixture +def temp_leaderboard_file(tmp_path: Path) -> Path: + """Фикстура: создает временный файл для хранения таблицы лидеров.""" + return tmp_path / "leaderboard.json" + + +@pytest.fixture +def sample_leaderboard() -> list[LeaderboardEntry]: + """Фикстура: возвращает тестовые данные таблицы лидеров с 3 записями.""" + return [ + LeaderboardEntry(place=1, name="Alice", score=100), + LeaderboardEntry(place=2, name="Bob", score=80), + LeaderboardEntry(place=3, name="Charlie", score=50), + ] + + +@patch("leaderboard.Path") +def test_load_leaderboard_no_file(mock_path: MagicMock, temp_leaderboard_file: Path) -> None: + """ + Загрузка таблицы лидеров при отсутствии файла. + Проверяет, что создается таблица с записями по умолчанию. + """ + mock_path.return_value = temp_leaderboard_file + + result = load_leaderboard() + + assert len(result) == _LEADERBOARD_SIZE + for i, entry in enumerate(result, 1): + assert entry.place == i + assert entry.name == _DEFAULT_NAME + assert entry.score == _DEFAULT_SCORE + + +@patch("leaderboard.Path") +def test_load_leaderboard_existing_file( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Загрузка таблицы лидеров из существующего файла. + Проверяет корректность чтения и формат данных. + """ + mock_path.return_value = temp_leaderboard_file + + with open(temp_leaderboard_file, "w", encoding="utf-8") as f: + json.dump([entry.model_dump() for entry in sample_leaderboard], f) + + result = load_leaderboard() + + assert len(result) == _LEADERBOARD_SIZE + assert result[0].name == "Alice" + assert result[1].score == 80 + assert result[2].place == 3 + + +@patch("leaderboard.Path") +def test_save_leaderboard( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Сохранение таблицы лидеров в файл. + Проверяет создание файла и корректность сохраненных данных. + """ + mock_path.return_value = temp_leaderboard_file + + save_leaderboard(sample_leaderboard) + + assert temp_leaderboard_file.exists() + with open(temp_leaderboard_file, "r", encoding="utf-8") as f: + data = json.load(f) + assert len(data) == _LEADERBOARD_SIZE + assert data[0]["name"] == "Alice" + assert data[1]["score"] == 80 + + +@patch("leaderboard.Path") +def test_update_leaderboard_new_high_score( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Обновление таблицы при попадании нового результата в топ. + Проверяет корректность обновления и сортировки записей. + """ + mock_path.return_value = temp_leaderboard_file + + with open(temp_leaderboard_file, "w", encoding="utf-8") as f: + json.dump([entry.model_dump() for entry in sample_leaderboard], f) + + updated = update_leaderboard("Dave", 90) + + assert updated is True + leaderboard = load_leaderboard() + assert leaderboard[0].name == "Alice" + assert leaderboard[1].name == "Dave" + assert leaderboard[1].score == 90 + assert leaderboard[2].name == "Bob" + + +@patch("leaderboard.Path") +def test_update_leaderboard_low_score( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Попытка обновления с результатом ниже минимального в таблице. + Проверяет, что таблица не изменяется при низком результате. + """ + mock_path.return_value = temp_leaderboard_file + + with open(temp_leaderboard_file, "w", encoding="utf-8") as f: + json.dump([entry.model_dump() for entry in sample_leaderboard], f) + + updated = update_leaderboard("Eve", 40) + + assert updated is False + leaderboard = load_leaderboard() + assert leaderboard[0].name == "Alice" + assert leaderboard[1].name == "Bob" + assert leaderboard[2].name == "Charlie" + + +@patch("leaderboard.Path") +def test_update_leaderboard_empty_slot(mock_path: MagicMock, temp_leaderboard_file: Path) -> None: + """ + Обновление пустой таблицы (все записи по умолчанию). + Проверяет добавление первой записи в таблицу. + """ + mock_path.return_value = temp_leaderboard_file + + initial_leaderboard = [ + LeaderboardEntry(place=i + 1, name=_DEFAULT_NAME, score=_DEFAULT_SCORE) for i in range(_LEADERBOARD_SIZE) + ] + save_leaderboard(initial_leaderboard) + + updated = update_leaderboard("FirstPlayer", 10) + + assert updated is True + leaderboard = load_leaderboard() + assert leaderboard[0].name == "FirstPlayer" + assert leaderboard[0].score == 10 + assert leaderboard[1].name == _DEFAULT_NAME + assert leaderboard[2].name == _DEFAULT_NAME + + +@patch("leaderboard.Path") +def test_leaderboard_size_maintained(mock_path: MagicMock, temp_leaderboard_file: Path) -> None: + """ + Проверка сохранения размера таблицы при множественных обновлениях. + Проверяет, что таблица никогда не превышает заданный размер. + """ + mock_path.return_value = temp_leaderboard_file + + for i in range(_LEADERBOARD_SIZE * 2): + update_leaderboard(f"Player{i}", i * 10) + leaderboard = load_leaderboard() + assert len(leaderboard) == _LEADERBOARD_SIZE + assert leaderboard[0].score == (_LEADERBOARD_SIZE * 2 - 1) * 10 + assert leaderboard[-1].score >= (_LEADERBOARD_SIZE - 1) * 10 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..90ba7b9 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,27 @@ +[mypy] + +python_version = 3.12 + +enable_error_code = truthy-bool, ignore-without-code + +check_untyped_defs = True + +strict_optional = True +disallow_any_generics = True +disallow_incomplete_defs = True +disallow_untyped_defs = True +no_implicit_optional = True + +warn_unused_ignores = True +warn_return_any = True +warn_no_return = True +warn_redundant_casts = True +warn_unreachable = True + +show_error_codes = True +show_column_numbers = True +pretty = True + +ignore_missing_imports = True +follow_imports = normal +follow_imports_for_stubs = True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..9e64b70 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,598 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "black" +version = "25.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "distlib" +version = "0.3.9" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + +[[package]] +name = "filelock" +version = "3.18.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, + {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + +[[package]] +name = "flake8" +version = "7.2.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, + {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.13.0,<2.14.0" +pyflakes = ">=3.3.0,<3.4.0" + +[[package]] +name = "identify" +version = "2.6.10" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "identify-2.6.10-py2.py3-none-any.whl", hash = "sha256:5f34248f54136beed1a7ba6a6b5c4b6cf21ff495aac7c359e1ef831ae3b8ab25"}, + {file = "identify-2.6.10.tar.gz", hash = "sha256:45e92fd704f3da71cc3880036633f48b4b7265fd4de2b57627cb157216eb7eb8"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["main", "dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "1.15.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, + {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pre-commit" +version = "4.2.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, + {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.13.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, + {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f"}, + {file = "pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.1" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26"}, + {file = "pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091"}, + {file = "pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383"}, + {file = "pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504"}, + {file = "pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24"}, + {file = "pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89"}, + {file = "pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8"}, + {file = "pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea"}, + {file = "pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a"}, + {file = "pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18"}, + {file = "pydantic_core-2.33.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb"}, + {file = "pydantic_core-2.33.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599"}, + {file = "pydantic_core-2.33.1-cp39-cp39-win32.whl", hash = "sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-win_amd64.whl", hash = "sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3"}, + {file = "pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyflakes" +version = "3.3.2" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, + {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, + {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "virtualenv" +version = "20.30.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6"}, + {file = "virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12, <3.14" +content-hash = "c623fd5285626d5fd4da5bc2c6f53336fc97b5d26c231551538686246ac80f3e" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..796b32a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[tool.poetry] +name = "Dots Game" +version = "0.0.1" +description = "Классическая игра 'Точки' для двух игроков с простым интерфейсом и стратегическим геймплеем. Захватывайте территории, окружая точки соперника, и побеждайте!" +authors = [ + "Samorodova Aleksandra", + "Nazarova Inessa", ] + +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.12, <3.14" +mypy = "^1.15.0" +black = "^25.1.0" +flake8 = "^7.2.0" +isort = "^6.0.1" +pre-commit = "^4.2.0" +pydantic = "^2.11.3" + +[tool.poetry.group.dev.dependencies] +pre-commit = "^4.2.0" +flake8 = "^7.2.0" + +[tool.black] +line-length = 120 +target-version = ['py312'] +skip-string-normalization = true + +[tool.isort] +profile = "black" +line_length = 120 +known_first_party = ["dots"] +sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] +default_section = "THIRDPARTY" +skip = [".venv", "venv", "build", "dist", "migrations"] +force_sort_within_sections = true +lines_after_imports = 2 +combine_as_imports = true +include_trailing_comma = true + +[tool.flake8] +max-line-length = 120 +ignore = "E203, W503" +exclude = ".git, __pycache__, .venv" +show-source = true