Enable D100-D107 docstring rules: add docstrings to all modules, classes, methods, and functions

- Added module docstrings to 19 Python files
- Added class docstrings to 5 classes (ScreenLocker, PokerModifierApp, etc.)
- Added method docstrings to 22 methods
- Added function docstrings to 25 functions
- Added __init__ docstrings to 5 classes
- Removed D100-D107 from ruff ignore list (docstrings now enforced)
- Removed deprecated ANN101, ANN102, UP038 rules from ignore list
- Fixed UP038: use union types in isinstance() calls
- All ruff checks now pass with full docstring enforcement
This commit is contained in:
Krzysztof kuhy Rudnicki 2025-11-30 14:45:55 +01:00
parent 1bc09449b5
commit 3ac56e541e
24 changed files with 111 additions and 12 deletions

View File

@ -1,3 +1,8 @@
"""Download cat images from TheCatAPI.
Fetches cat images in batches and saves them to a local directory.
"""
import json
import logging
import os

View File

@ -55,6 +55,7 @@ def extract_hosts_from_html(html_text: str) -> list[str]:
def main() -> int:
"""Parse command-line arguments and extract hosts from an HTML file."""
ap = argparse.ArgumentParser(
description="Extract hosts from hrefs in an HTML file."
)

View File

@ -1,3 +1,5 @@
"""Unit tests for link extraction functionality."""
from pathlib import Path
import subprocess
import sys
@ -10,10 +12,12 @@ SCRIPT = ROOT / "main.py"
def read_lines(p: Path):
"""Read lines from a file, stripping newlines."""
return [l.rstrip("\n") for l in p.read_text(encoding="utf-8").splitlines()]
def test_extract_hosts_function():
"""Test extract_hosts_from_html extracts unique hosts in order."""
from main import extract_hosts_from_html
html = (
@ -28,6 +32,7 @@ def test_extract_hosts_function():
def test_cli_writes_expected_output(tmp_path: Path):
"""Test CLI writes correctly formatted output file."""
# copy sample1.html to tmpdir and run the script
sample = ROOT / "tests" / "sample1.html"
html_copy = tmp_path / "sample1.html"
@ -49,6 +54,7 @@ def test_cli_writes_expected_output(tmp_path: Path):
def test_cli_default_output_name(tmp_path: Path):
"""Test CLI generates default output filename from input."""
sample = ROOT / "tests" / "sample2.html"
html_copy = tmp_path / "sample2.html"
html_copy.write_text(sample.read_text(encoding="utf-8"), encoding="utf-8")

View File

@ -1,3 +1,8 @@
"""Keyboard cooperative word game using Pygame.
Players take turns selecting adjacent keys to form valid English words.
"""
import json
import logging
import os
@ -62,7 +67,10 @@ KEY_ADJACENCY = {
class KeyboardCoopGame:
"""Main game class for the keyboard cooperative word game."""
def __init__(self):
"""Initialize the game window, fonts, and game state."""
self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Keyboard Coop Game")
self.clock = pygame.time.Clock()

View File

@ -1,3 +1,5 @@
"""Chess engine wrapper for the C-based random/scoring engine."""
import os
import subprocess
@ -23,6 +25,7 @@ class RandomEngine:
max_time_sec: float = 2.0,
depth: int | None = None,
):
"""Initialize the engine wrapper with path and time settings."""
self.max_time_sec = max_time_sec
# depth is accepted for compatibility with existing callers but is unused;
# the C engine handles its own scoring/selection.
@ -67,6 +70,7 @@ class RandomEngine:
return (proc.stdout or "").strip()
def choose_move(self, board: chess.Board) -> chess.Move:
"""Choose a move for the given board position."""
mv, _ = self.choose_move_with_explanation(
board, time_budget_sec=self.max_time_sec
)
@ -75,6 +79,7 @@ class RandomEngine:
def choose_move_with_explanation(
self, board: chess.Board, *, time_budget_sec: float
) -> tuple[chess.Move | None, str]:
"""Choose a move and return explanation for the decision."""
# Collect legal moves and send to engine as plain UCI tokens.
legal = list(board.legal_moves)
if not legal:
@ -135,7 +140,7 @@ class RandomEngine:
# candidate score if provided
analyze = data.get("analyze") or {}
cs = analyze.get("candidate_score")
if isinstance(cs, (int, float)):
if isinstance(cs, int | float):
cand_score = float(cs)
# best move
chosen = data.get("chosen_move")

View File

@ -1,3 +1,5 @@
"""Lichess API client for bot interactions."""
from collections.abc import Generator
import contextlib
import json
@ -11,7 +13,10 @@ LICHESS_API = "https://lichess.org"
class LichessAPI:
"""Client for interacting with the Lichess Bot API."""
def __init__(self, token: str, session: requests.Session | None = None):
"""Initialize the API client with authentication token."""
self.token = token
self.session = session or requests.Session()
self.session.headers.update(
@ -62,6 +67,7 @@ class LichessAPI:
return r
def stream_events(self) -> Generator[dict, None, None]:
"""Stream incoming events (challenges, game starts, etc.)."""
url = f"{LICHESS_API}/api/stream/event"
backoff = 0.5
while True:
@ -90,10 +96,12 @@ class LichessAPI:
raise
def accept_challenge(self, challenge_id: str) -> None:
"""Accept a challenge by its ID."""
url = f"{LICHESS_API}/api/challenge/{challenge_id}/accept"
self._request("POST", url, timeout=30, raise_for_status=True)
def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None:
"""Decline a challenge with an optional reason."""
url = f"{LICHESS_API}/api/challenge/{challenge_id}/decline"
data = {"reason": reason}
self._request("POST", url, data=data, timeout=30, raise_for_status=True)
@ -135,6 +143,7 @@ class LichessAPI:
return board, color
def stream_game_events(self, game_id: str) -> Generator[dict, None, None]:
"""Stream game state events for a specific game."""
url = f"{LICHESS_API}/api/board/game/stream/{game_id}"
headers = {"Accept": "application/x-ndjson"}
with self._request("GET", url, headers=headers, stream=True, timeout=None) as r:
@ -148,6 +157,7 @@ class LichessAPI:
logging.debug(f"Skipping non-JSON line in game {game_id}: {line}")
def make_move(self, game_id: str, move: chess.Move) -> None:
"""Submit a move to an active game."""
url = f"{LICHESS_API}/api/board/game/{game_id}/move/{move.uci()}"
r = self._request("POST", url, timeout=30)
if r.status_code in (400, 409):
@ -165,6 +175,7 @@ class LichessAPI:
return None
def get_my_user_id(self) -> str | None:
"""Fetch the authenticated user's ID."""
url = f"{LICHESS_API}/api/account"
r = self._request("GET", url, timeout=30)
if r.status_code == 200:

View File

@ -1,3 +1,5 @@
"""Main entry point for the Lichess bot."""
import argparse
import json
import logging
@ -15,6 +17,7 @@ from PYTHON.lichess_bot.utils import backoff_sleep, get_and_increment_version
def run_bot(log_level: str = "INFO", decline_correspondence: bool = False) -> None:
"""Start the bot and listen for incoming events."""
logging.basicConfig(
level=getattr(logging, log_level.upper(), logging.INFO),
format="[%(asctime)s] %(levelname)s %(threadName)s: %(message)s",
@ -448,6 +451,7 @@ def run_bot(log_level: str = "INFO", decline_correspondence: bool = False) -> No
def main():
"""Parse arguments and run the Lichess bot."""
parser = argparse.ArgumentParser(description="Run a minimal Lichess bot")
parser.add_argument(
"--log-level", default="INFO", help="Logging level (default: INFO)"

View File

@ -1,3 +1,5 @@
"""Test the engine against Lichess puzzles."""
import csv
import os
@ -9,6 +11,7 @@ from PYTHON.lichess_bot.engine import RandomEngine
def _load_top_puzzles(csv_path: str, limit: int = 8) -> list[tuple[str, str]]:
"""Return a list of (FEN, solution_moves_str) for the first `limit` rows in the CSV.
CSV columns: PuzzleId,FEN,Moves,...
"""
puzzles: list[tuple[str, str]] = []
@ -31,6 +34,7 @@ def _load_top_puzzles(csv_path: str, limit: int = 8) -> list[tuple[str, str]]:
),
)
def test_puzzle_engine_follow_solution(fen: str, moves_str: str):
"""Verify the engine follows puzzle solutions correctly."""
board = chess.Board(fen)
eng = RandomEngine(max_time_sec=1.0)

View File

@ -1,7 +1,10 @@
"""Tests for utility functions."""
from PYTHON.lichess_bot.utils import backoff_sleep
def test_backoff_sleep_increments_and_caps(monkeypatch):
"""Test that backoff sleep increments and respects the cap."""
slept = []
def fake_sleep(sec):

View File

@ -1,7 +1,10 @@
"""Tests for bot version management."""
from PYTHON.lichess_bot.utils import get_and_increment_version
def test_version_file_increments_and_persists(tmp_path, monkeypatch):
"""Test that version increments and persists to file."""
version_file = tmp_path / "version.txt"
monkeypatch.setenv("LICHESS_BOT_VERSION_FILE", str(version_file))

View File

@ -48,6 +48,8 @@ logging.basicConfig(level=logging.INFO)
@dataclass
class Blunder:
"""Data class representing a blunder move from analysis."""
ply: int
side: str # 'W' or 'B'
san: str # SAN of the played blunder
@ -55,6 +57,7 @@ class Blunder:
def parse_columns_for_blunders(text: str) -> list[Blunder]:
"""Parse the Columns section of a log file to extract blunders."""
lines = text.splitlines()
# Find start of "Columns:" block
try:
@ -107,6 +110,7 @@ def parse_columns_for_blunders(text: str) -> list[Blunder]:
def extract_pgn(text: str) -> str | None:
"""Extract the PGN block from a log file."""
# Extract the PGN block after a line that is exactly 'PGN:' or starts with it
m = re.search(r"^PGN:\s*$", text, flags=re.MULTILINE)
if not m:
@ -117,6 +121,7 @@ def extract_pgn(text: str) -> str | None:
def san_list_from_game(game: chess.pgn.Game) -> list[str]:
"""Extract the list of SAN moves from a PGN game."""
san_moves: list[str] = []
node = game
while node.variations:
@ -128,6 +133,7 @@ def san_list_from_game(game: chess.pgn.Game) -> list[str]:
def fen_and_uci_for_blunders(
pgn_text: str, blunders: list[Blunder]
) -> list[tuple[str, str, str, Blunder]]:
"""Convert blunders to (FEN, UCI, best_UCI, Blunder) tuples."""
game = chess.pgn.read_game(io.StringIO(pgn_text))
if game is None:
msg = "Failed to parse PGN from log"
@ -173,6 +179,7 @@ def fen_and_uci_for_blunders(
def ensure_unified_test_file(target_path: str) -> None:
"""Create the unified test file skeleton if it doesn't exist."""
os.makedirs(os.path.dirname(target_path), exist_ok=True)
if os.path.exists(target_path):
return
@ -370,6 +377,7 @@ def _process_single_log(log_path: str) -> int:
def main(argv: list[str]) -> int:
"""Process log files and generate blunder test cases."""
script_dir = os.path.dirname(__file__)
past_dir = os.path.abspath(os.path.join(script_dir, "past_games"))

View File

@ -1,3 +1,5 @@
"""Utility functions for the Lichess bot."""
import logging
import os
import time

View File

@ -1,7 +1,10 @@
"""Mitmproxy addon to simulate connection failures."""
from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
"""Intercept requests and simulate failures for specific hosts."""
# Only intercept traffic to example.com
if "example.com" in flow.request.host:
flow.response = http.Response.make(

View File

@ -1,3 +1,5 @@
"""Generate random colorful JPEG images with configurable parameters."""
import argparse
from datetime import datetime
import logging

View File

@ -1,3 +1,5 @@
"""Randomize numbers by applying a random percentage variation."""
import contextlib
import logging
import random
@ -8,6 +10,7 @@ logging.basicConfig(level=logging.INFO)
def randomize_numbers(numbers, min_percentage=1, max_percentage=20):
"""Apply random percentage variation to a list of numbers."""
randomized_numbers = []
for number in numbers:
percentage = random.uniform(min_percentage, max_percentage) / 100
@ -20,6 +23,7 @@ def randomize_numbers(numbers, min_percentage=1, max_percentage=20):
def parse_input(input_string):
"""Parse a string of numbers and return floats with decimal counts."""
# Replace commas with dots and remove non-numeric characters
# except dots, commas, and digits
cleaned_input = re.sub(r"[^\d.,\s]", "", input_string).replace(",", ".")

View File

@ -1,3 +1,5 @@
"""Download comic images from a website using Selenium."""
import argparse
import logging
import os
@ -27,6 +29,7 @@ driver.get(url)
# A function to download images by URL
def download_image(url):
"""Download an image from a URL and save it locally."""
# Extract image name from URL
image_name = os.path.basename(urlparse(url).path)

View File

@ -14,7 +14,10 @@ logging.basicConfig(level=logging.INFO)
class ScreenLocker:
"""Screen locker that requires workout logging to unlock."""
def __init__(self, demo_mode=True):
"""Initialize screen locker with optional demo mode."""
# Set up log file path
script_dir = os.path.dirname(os.path.abspath(__file__))
self.log_file = os.path.join(script_dir, "workout_log.json")
@ -74,10 +77,12 @@ class ScreenLocker:
self.root.grab_set_global()
def clear_container(self):
"""Remove all widgets from the main container."""
for widget in self.container.winfo_children():
widget.destroy()
def ask_workout_done(self):
"""Display the initial workout question dialog."""
self.clear_container()
question = tk.Label(
@ -117,6 +122,7 @@ class ScreenLocker:
no_btn.pack(side="left", padx=20)
def lockout(self):
"""Display lockout screen with countdown timer."""
self.clear_container()
self.lockout_label = tk.Label(
@ -141,6 +147,7 @@ class ScreenLocker:
self.update_lockout_countdown()
def update_lockout_countdown(self):
"""Update the lockout countdown timer display."""
if self.remaining_time > 0:
self.countdown_label.config(text=str(self.remaining_time))
self.remaining_time -= 1
@ -149,6 +156,7 @@ class ScreenLocker:
self.ask_workout_done()
def ask_workout_type(self):
"""Display workout type selection dialog."""
self.clear_container()
question = tk.Label(
@ -188,6 +196,7 @@ class ScreenLocker:
strength_btn.pack(side="left", padx=20)
def ask_running_details(self):
"""Display running workout input form."""
self.clear_container()
self.workout_data["type"] = "running"
@ -277,6 +286,7 @@ class ScreenLocker:
self.update_submit_timer()
def verify_running_data(self):
"""Validate running workout data and unlock if valid."""
try:
distance = float(self.distance_entry.get())
time_mins = float(self.time_entry.get())
@ -314,6 +324,7 @@ class ScreenLocker:
self.show_error("Please enter valid numbers")
def ask_strength_details(self):
"""Display strength training input form."""
self.clear_container()
self.workout_data["type"] = "strength"
@ -435,6 +446,7 @@ class ScreenLocker:
self.update_submit_timer()
def verify_strength_data(self):
"""Validate strength workout data and unlock if valid."""
try:
exercises = [e.strip() for e in self.exercises_entry.get().split(",")]
sets = [int(s.strip()) for s in self.sets_entry.get().split(",")]
@ -539,6 +551,7 @@ class ScreenLocker:
pass
def show_error(self, message):
"""Display error message with retry option."""
self.clear_container()
error_label = tk.Label(
@ -573,6 +586,7 @@ class ScreenLocker:
retry_btn.pack(pady=30)
def unlock_screen(self):
"""Save workout log and display success message."""
# Save workout data to log
self.save_workout_log()
@ -638,10 +652,12 @@ class ScreenLocker:
logging.warning(f"Could not save workout log: {e}")
def close(self):
"""Close the application and exit."""
self.root.destroy()
sys.exit(0)
def run(self):
"""Start the Tkinter main event loop."""
self.root.mainloop()

View File

@ -1,3 +1,6 @@
"""Distribute values symmetrically across N parts."""
def calculate_symmetric_weights(N, middle_weight, factors=None):
"""Calculate symmetric weights for both even and odd N.

View File

@ -125,6 +125,7 @@ def classify_cp_loss(cp_loss: int | None) -> str:
def fmt_eval(cp: int | None, mate_in: int | None) -> str:
"""Format evaluation score as human-readable string."""
if mate_in is not None:
sign = "+" if mate_in > 0 else ""
return f"M{sign}{mate_in}"
@ -201,6 +202,7 @@ def _auto_hash_mb(threads_wanted: int, engine_options) -> int:
def main():
"""Parse arguments and run chess game analysis."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
ap = argparse.ArgumentParser(
description="Analyze a chess game's moves with Stockfish and rate each move."

View File

@ -1,3 +1,5 @@
"""Sort images into folders using keyboard input."""
import logging
import os # for: os.getcwd; os.mkdir; os.listdir;
from os import path # for: os.path.abspath

View File

@ -1,3 +1,5 @@
"""Integration tests for the articles C server API."""
import json
import os
from pathlib import Path
@ -9,7 +11,8 @@ import urllib.request
def _req(url, method="GET", data=None):
if data is not None and not isinstance(data, (bytes, bytearray)):
"""Send an HTTP request and return status code and body."""
if data is not None and not isinstance(data, bytes | bytearray):
data = json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
@ -19,6 +22,7 @@ def _req(url, method="GET", data=None):
def test_crud_roundtrip(tmp_path):
"""Test full CRUD lifecycle for articles API."""
# Build C server
here = Path(__file__).resolve().parent
subprocess.run(["make", "-s", "server_c"], check=True, cwd=str(here))

View File

@ -1,3 +1,5 @@
"""Tests to ensure website stays within size budget."""
import os
# Budget for the entire website (single file) in bytes
@ -8,9 +10,11 @@ SITE_FILE = os.path.join(HERE, "index.html")
def test_site_file_exists():
"""Verify the main site HTML file exists."""
assert os.path.exists(SITE_FILE), f"Missing site file: {SITE_FILE}"
def test_site_size_under_budget():
"""Verify site size is under the defined budget."""
size = os.path.getsize(SITE_FILE)
assert size <= BUDGET, f"Site size {size} bytes exceeds budget {BUDGET}"

View File

@ -1,3 +1,5 @@
"""Texas Hold'em poker game modifier application."""
import logging
import random
import tkinter as tk
@ -7,7 +9,10 @@ logging.basicConfig(level=logging.INFO)
class PokerModifierApp:
"""GUI application for poker game modifiers."""
def __init__(self):
"""Initialize the poker modifier app with default settings."""
self.modifiers = [
# Hand Bonus Modifiers (Balatro-inspired)
{
@ -515,6 +520,7 @@ class PokerModifierApp:
self.setup_gui()
def setup_gui(self):
"""Create and configure the main GUI window."""
# Create main window
self.root = tk.Tk()
self.root.title("🃏 Texas Hold'em Modifier")

View File

@ -33,15 +33,7 @@ ignore = [
"D213", # Multi-line docstring summary should start at second line (conflicts with D212)
"COM812", # Trailing comma missing (conflicts with formatter)
"ISC001", # Implicit string concatenation (conflicts with formatter)
"ANN101", # Missing type annotation for self (deprecated)
"ANN102", # Missing type annotation for cls (deprecated)
# Relaxed for script-heavy repository
"D100", # Missing docstring in public module - scripts don't need module docstrings
"D101", # Missing docstring in public class - relaxed
"D102", # Missing docstring in public method - relaxed
"D103", # Missing docstring in public function - relaxed
"D104", # Missing docstring in public package - relaxed
"D107", # Missing docstring in __init__ - relaxed
"D205", # Missing blank line after summary - relaxed
"D415", # Missing terminal punctuation - relaxed for scripts
"INP001", # Implicit namespace package - this is a scripts repo
@ -98,11 +90,9 @@ ignore = [
"B023", # function-uses-loop-variable - common pattern with closures
"B904", # raise-without-from - style preference
"DTZ005", # datetime.now without tz - acceptable for local scripts
"UP038", # isinstance union type - py3.10+ style, not required
"E741", # ambiguous-variable-name - sometimes intentional (e.g. l for list)
"DTZ004", # datetime.utcfromtimestamp - acceptable
"E722", # bare-except - will be fixed where critical
"E741", # ambiguous-variable-name - sometimes intentional
"PT017", # pytest-assert-in-except - acceptable pattern
]