diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abcf4e0..f017022 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,17 +35,7 @@ jobs: run: ruff check . - name: Run tests with coverage - run: | - pytest --cov=src --cov-report=term-missing --cov-report=xml - exit_code=$? - # Exit code 5 means no tests were collected (tests/ not yet present). - # Treat this as a warning rather than a hard failure so the CI - # pipeline can be merged ahead of the test suite PR. - if [ $exit_code -eq 5 ]; then - echo "::warning::No tests collected — tests/ directory may not exist on this branch yet." - exit 0 - fi - exit $exit_code + run: pytest --cov=src --cov-report=term-missing --cov-report=xml - name: Upload coverage report uses: actions/upload-artifact@v4 diff --git a/pyproject.toml b/pyproject.toml index 779e051..568664b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "pandas~=2.3.3", "requests~=2.32.5", "openpyxl~=3.1.5", - "xlsxwriter~=3.2.9", "python-dotenv~=1.2.1", ] @@ -20,6 +19,9 @@ dev = [ "ruff~=0.15.2", ] +[project.scripts] +chesscom = "chesscom.cli:main" + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --tb=short" diff --git a/src/chesscom/__main__.py b/src/chesscom/__main__.py new file mode 100644 index 0000000..305d99d --- /dev/null +++ b/src/chesscom/__main__.py @@ -0,0 +1,6 @@ +"""Enable ``python -m chesscom`` invocation.""" + +from chesscom.cli import main + +if __name__ == "__main__": + main() diff --git a/src/chesscom/cli.py b/src/chesscom/cli.py new file mode 100644 index 0000000..0bda8d8 --- /dev/null +++ b/src/chesscom/cli.py @@ -0,0 +1,187 @@ +"""Command-line interface for the Chess.com club management tools. + +Exposes four subcommands, each corresponding to one of the report classes: + +* ``match-participation`` — :class:`~chesscom.reports.match_participation.MatchParticipationReport` +* ``member-summary`` — :class:`~chesscom.reports.member_summary.MemberSummaryReport` +* ``prospects`` — :class:`~chesscom.reports.prospect.ProspectReport` +* ``match-eligibility`` — :class:`~chesscom.reports.match_eligibility.MatchEligibilityReport` + +Usage:: + + python -m chesscom [options] + +All configuration is loaded from environment variables (see ``.env.template``). +A ``.env`` file in the project root is automatically sourced at startup. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import sys +import time + +from dotenv import load_dotenv + +from chesscom.api.client import ChessComClient +from chesscom.config import AppConfig +from chesscom.reports.base import BaseReport +from chesscom.reports.match_eligibility import MatchEligibilityReport +from chesscom.reports.match_participation import MatchParticipationReport +from chesscom.reports.member_summary import MemberSummaryReport +from chesscom.reports.prospect import ProspectReport + +# --------------------------------------------------------------------------- +# Timing helper +# --------------------------------------------------------------------------- + + +def _run_timed(report: BaseReport) -> None: + """Run *report* and print the output path and elapsed time.""" + start = time.monotonic() + path = report.run() + elapsed = time.monotonic() - start + print(f"Report written: {path}") + print(f"Execution time: {elapsed:.2f}s ({elapsed / 60:.2f} min)") + + +# --------------------------------------------------------------------------- +# Subcommand handlers +# --------------------------------------------------------------------------- + + +def _handle_match_participation(args: argparse.Namespace) -> None: # noqa: ARG001 + config = AppConfig.from_env() + _run_timed(MatchParticipationReport(ChessComClient(), config)) + + +def _handle_member_summary(args: argparse.Namespace) -> None: # noqa: ARG001 + config = AppConfig.from_env() + _run_timed(MemberSummaryReport(ChessComClient(), config)) + + +def _handle_prospects(args: argparse.Namespace) -> None: # noqa: ARG001 + config = AppConfig.from_env() + _run_timed(ProspectReport(ChessComClient(), config)) + + +def _handle_match_eligibility(args: argparse.Namespace) -> None: + config = AppConfig.from_env() + # --match-id CLI flag overrides (or supplies) the MATCH_ID env var + if args.match_id: + config = dataclasses.replace(config, match_id=args.match_id) + _run_timed(MatchEligibilityReport(ChessComClient(), config)) + + +# --------------------------------------------------------------------------- +# Argument parser +# --------------------------------------------------------------------------- + +_HANDLERS = { + "match-participation": _handle_match_participation, + "member-summary": _handle_member_summary, + "prospects": _handle_prospects, + "match-eligibility": _handle_match_eligibility, +} + + +def build_parser() -> argparse.ArgumentParser: + """Construct and return the top-level :class:`argparse.ArgumentParser`. + + Returns: + Fully configured parser with all four subcommands registered. + """ + parser = argparse.ArgumentParser( + prog="chesscom", + description="Chess.com club management tools.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "All options are read from environment variables.\n" + "Copy .env.template to .env and fill in the values before running." + ), + ) + + sub = parser.add_subparsers( + dest="subcommand", + required=True, + metavar="", + ) + + sub.add_parser( + "match-participation", + help="Export club contribution / match-participation report.", + description=( + "Analyses member participation and win rates across all team matches " + "completed in DATA_ANALYSIS_YEAR and writes a two-sheet Excel workbook." + ), + ) + + sub.add_parser( + "member-summary", + help="Export a roster of all current club members with key stats.", + description=( + "Fetches every club member's profile and stats from the Chess.com API " + "and writes a single-sheet Excel workbook." + ), + ) + + sub.add_parser( + "prospects", + help="Export a de-duplicated prospect list from multiple clubs.", + description=( + "Collects members from LIST_OF_CLUBS, removes anyone already in " + "EXCLUSION_CLUB, de-duplicates, and exports to Excel." + ), + ) + + me_parser = sub.add_parser( + "match-eligibility", + help="Export eligible members for a specific team match.", + description=( + "Lists club members whose rating falls within the match cap for the " + "detected chess variant (standard or Chess960) and flags signed-up players." + ), + ) + me_parser.add_argument( + "--match-id", + metavar="ID", + default=None, + help=( + "Chess.com match ID to analyse " + "(overrides MATCH_ID env var; required if MATCH_ID is not set)." + ), + ) + + return parser + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> None: + """Parse *argv* (or ``sys.argv[1:]``) and dispatch to the correct handler. + + Args: + argv: Argument list to parse. When ``None`` the process argument + vector is used (standard ``argparse`` behaviour). + + Raises: + SystemExit(0): On ``--help``. + SystemExit(1): On configuration or validation errors. + SystemExit(130): On ``KeyboardInterrupt``. + """ + load_dotenv() + parser = build_parser() + args = parser.parse_args(argv) + + try: + _HANDLERS[args.subcommand](args) + except ValueError as exc: + print(f"Configuration error: {exc}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + print("\nInterrupted.", file=sys.stderr) + sys.exit(130) diff --git a/src/club_contribution_report.py b/src/club_contribution_report.py deleted file mode 100644 index bc4bc55..0000000 --- a/src/club_contribution_report.py +++ /dev/null @@ -1,245 +0,0 @@ -import os -import time -from datetime import UTC, datetime - -import pandas as pd - -import utils - -# Environment variables -CLUB_REF = os.getenv('CLUB_REF') -CLUB_NAME = os.getenv('CLUB_NAME') -DATA_ANALYSIS_YEAR = os.getenv('DATA_ANALYSIS_YEAR') - - -# Returns a list of all club members -def get_all_club_members(): - url = f'https://api.chess.com/pub/club/{CLUB_REF}/members' - - response = utils.request_handler(url, headers=utils.headers) - - members = response.json().get('weekly', []) + response.json().get('monthly', []) + response.json().get('all_time', []) - - return members - - -# Returns the daily rating for a member -def get_member_daily_rating(username): - url = f'https://api.chess.com/pub/player/{username}/stats' - - response = utils.request_handler(url, headers=utils.headers) - - stats = response.json() - - return stats.get('chess_daily', {}).get('last', {}).get('rating', 'N/A') - - -# Returns the last online date for a member -def get_member_last_online(username): - url = f'https://api.chess.com/pub/player/{username}' - - response = utils.request_handler(url, headers=utils.headers) - - profile = response.json() - last_online = profile.get('last_online', 0) - - return datetime.fromtimestamp(last_online, tz=UTC).strftime('%d/%m/%Y') - - -def get_timeout_percentage(username): - url = f'https://api.chess.com/pub/player/{username}/stats' - - response = utils.request_handler(url, headers=utils.headers) - - stats = response.json().get('chess_daily', {}) - timeout_percent = stats.get('record', {}).get('timeout_percent', 0) - - return timeout_percent - - -# Returns the date a member joined the club -def get_chesscom_joined_date(username): - url = f'https://api.chess.com/pub/player/{username}' - - response = utils.request_handler(url, headers=utils.headers) - - profile = response.json() - joined_date = profile.get('joined', 0) - - return datetime.fromtimestamp(joined_date, tz=UTC).strftime('%d/%m/%Y') - - -def get_member_joined_club(club, username): - url = f'https://api.chess.com/pub/club/{club}/members' - - response = utils.request_handler(url, headers=utils.headers) - - data = response.json() - all_members = data.get('weekly', []) + data.get('monthly', []) + data.get('all_time', []) - - for member in all_members: - if member.get('username') == username: - joined_timestamp = member.get('joined', 0) - - return datetime.fromtimestamp(joined_timestamp, tz=UTC).strftime('%d/%m/%Y') - - -# Returns all matches for the club in a given year -def get_all_club_matches_in_year(year): - url = f'https://api.chess.com/pub/club/{CLUB_REF}/matches' - - response = utils.request_handler(url, headers=utils.headers) - - all_matches = response.json().get('finished', []) - matches_in_year = [match for match in all_matches if time.gmtime(match['start_time']).tm_year >= int(year)] - - return matches_in_year - - -# Returns match data listing participants and results -def get_match_data(url): - response = utils.request_handler(url, headers=utils.headers) - - - match_data = response.json() - participants = {} - teams = match_data.get('teams', {}) - team_scotland = None - - if teams.get('team1', {}).get('name') == CLUB_NAME: - team_scotland = teams.get('team1', {}) - elif teams.get('team2', {}).get('name') == CLUB_NAME: - team_scotland = teams.get('team2', {}) - - if team_scotland: - for player in team_scotland.get('players', []): - username = player['username'] - result_white = player.get('played_as_white', 'in progress') - result_black = player.get('played_as_black', 'in progress') - - participants[username] = { - 'result_white': result_white, 'result_black': result_black - } - - return participants - - -# Calculate the participation percentage for a member against club matches -def calculate_participation_percentage(matches_played, matches_participated): - if matches_played == 0: - return 0 - return round((matches_participated / matches_played) * 100, 2) - - -# Calculate the win rate percentage for a member based on completed games -def calculate_win_rate(wins, losses, draws): - total_games = wins + losses + draws - if total_games == 0: - return 0 - return round((wins / total_games) * 100, 2) - - -# Export the data to an Excel file -def export_to_excel(members_data, matches_data, filename='output/default.xlsx'): - df_members = pd.DataFrame(members_data) - df_matches = pd.DataFrame(matches_data) - - with pd.ExcelWriter(filename, engine='xlsxwriter') as writer: - df_members.to_excel(writer, sheet_name='Member Metrics', index=False) - df_matches.to_excel(writer, sheet_name='Match Data', index=False) - - worksheet = writer.sheets['Member Metrics'] - - # Add hyperlinks to usernames - for row_num, username in enumerate(df_members['Username'], start=1): - profile_url = f'https://www.chess.com/member/{username}' - worksheet.write_url(row_num, 0, profile_url, string=username) - - -@utils.calculate_execution_time -def main(): - # members = [{'username': 'leighdastey'}, - # {'username': 'andrewmoulden'}, - # {'username': 'jules64'}] # Test users - members = get_all_club_members() - - # Fetch all matches first - matches = get_all_club_matches_in_year(DATA_ANALYSIS_YEAR) - total_matches = len(matches) - - # Pre-process all match data in one go - all_match_data = {} - matches_data = [] - for match in matches: - match_name = match['name'] - match_url = match['@id'] - match_info = {'Match Name': match_name, 'Match URL': match_url} - - # Get match data once per match - match_data = get_match_data(match_url) - all_match_data[match_url] = match_data - matches_data.append(match_info) - - # Process member data separately from match iteration - members_data = [] - for member in members: - username = member['username'] - matches_participated = 0 - timeouts = 0 - wins = 0 - losses = 0 - draws = 0 - - # Fill in match data for this member - for i, match in enumerate(matches): - match_url = match['@id'] - match_data = all_match_data[match_url] - - if username in match_data: - matches_participated += 1 - result_white = match_data[username]['result_white'] - result_black = match_data[username]['result_black'] - - # Add results to match_info - matches_data[i][f"{username}_white"] = result_white - matches_data[i][f"{username}_black"] = result_black - - # Count results for win rate calculation (only completed games) - for result in [result_white, result_black]: - if result == 'win': - wins += 1 - elif result == 'timeout': - timeouts += 1 - losses += 1 # Count timeouts as losses - elif result == 'checkmated' or result == 'resigned': - losses += 1 - elif result == 'agreed' or result == 'repetition' or result == 'stalemate' or result == 'insufficient': - draws += 1 - else: - matches_data[i][f"{username}_white"] = 'not played' - matches_data[i][f"{username}_black"] = 'not played' - - # Collate member data for export - member_data = { - 'Username': username, - 'Daily Rating': get_member_daily_rating(username), - 'Joined Chess.com': get_chesscom_joined_date(username), - 'Joined Club': get_member_joined_club(CLUB_REF, username), - 'Last Online': get_member_last_online(username), - 'Timeout Percentage': get_timeout_percentage(username), - 'Club Timeouts': timeouts, - 'Total Matches': total_matches, - 'Participation %': calculate_participation_percentage( - total_matches, matches_participated), - 'Win Rate %': calculate_win_rate(wins, losses, draws) - } - members_data.append(member_data) - - file = utils.get_unique_filename('output', f'{CLUB_NAME} Club Contribution Report {DATA_ANALYSIS_YEAR}', 'xlsx') - export_to_excel(members_data, matches_data, file) - utils.print_line(f'Data exported to {file} ... program executed successfully') - - -if __name__ == "__main__": - utils.init() - main() diff --git a/src/generate_club_member_report.py b/src/generate_club_member_report.py deleted file mode 100644 index abf2e57..0000000 --- a/src/generate_club_member_report.py +++ /dev/null @@ -1,97 +0,0 @@ -import os -from datetime import UTC, datetime - -import pandas as pd - -import utils - - -def get_club_members(club): - url = f'https://api.chess.com/pub/club/{club}/members' - - response = utils.request_handler(url, headers=utils.headers) - - data = response.json() - - return data.get('weekly', []) + data.get('monthly', []) + data.get('all_time', []) - - -def get_member_joined_club(club, username): - url = f'https://api.chess.com/pub/club/{club}/members' - - response = utils.request_handler(url, headers=utils.headers) - - data = response.json() - all_members = data.get('weekly', []) + data.get('monthly', []) + data.get('all_time', []) - - for member in all_members: - if member.get('username') == username: - joined_timestamp = member.get('joined', 0) - return datetime.fromtimestamp(joined_timestamp, tz=UTC).strftime('%d/%m/%Y') - - return [] - - -def get_member_info(username, club): - url = f'https://api.chess.com/pub/player/{username}/stats' - - response = utils.request_handler(url, headers=utils.headers) - - stats_data = response.json() - - # Get standard chess daily stats - chess_stats = stats_data.get('chess_daily', {}) - chess_rating = chess_stats.get('last', {}).get('rating', 0) - timeout_percent = chess_stats.get('record', {}).get('timeout_percent', 0) - - # Get Chess960 daily stats - chess960_stats = stats_data.get('chess960_daily', {}) - chess960_rating = chess960_stats.get('last', {}).get('rating', 'Unrated') if chess960_stats else 'Unrated' - - url = f'https://api.chess.com/pub/player/{username}' - - response = utils.request_handler(url, headers=utils.headers) - - profile = response.json() - - name = profile.get('name', '') - title = profile.get('title', '') - last_online = datetime.fromtimestamp(profile.get('last_online', 0), tz=UTC).strftime('%d/%m/%Y') - joined = datetime.fromtimestamp(profile.get('joined', 0), tz=UTC).strftime('%d/%m/%Y') - - return { - 'FIDE Title': title, - 'Username': username, - 'Name': name, - 'Joined Chess.com': joined, - 'Joined Club': get_member_joined_club(club, username), - 'Last Online': last_online, - 'Daily Rating': chess_rating, - 'Chess960 Rating': chess960_rating, - 'Timeout Percentage': timeout_percent - } - - -@utils.calculate_execution_time -def main(club): - results = [] - - members = get_club_members(club) - - for member in members: - results.append(get_member_info(member.get('username'), club)) - - df = pd.DataFrame(results) - - file = utils.get_unique_filename('output', 'Club Member Summary Report', 'xlsx') - df.to_excel(file, index=False, sheet_name='Club Member Summary Report') - utils.print_line(f'Excel file created: {file}') - - -if __name__ == "__main__": - utils.init() - - try: - main(os.getenv('CLUB_REF')) - except Exception as e: - utils.print_line(f'Error: {e}') diff --git a/src/generate_prospect_data.py b/src/generate_prospect_data.py deleted file mode 100644 index 4633ede..0000000 --- a/src/generate_prospect_data.py +++ /dev/null @@ -1,106 +0,0 @@ -import os -from datetime import UTC, datetime - -import pandas as pd -import requests - -import utils - - -def fetch_club_members(club): - url = f'https://api.chess.com/pub/club/{club}/members' - - response = requests.get(url, headers=utils.headers) - - if response.status_code == 200: - data = response.json() - return data.get('weekly', []) + data.get('monthly', []) + data.get('all_time', []) - return [] - - -def fetch_member_info(username, club): - url = f'https://api.chess.com/pub/player/{username}/stats' - - response = requests.get(url, headers=utils.headers) - - if response.status_code == 200: - stats = response.json().get('chess_daily', {}) - rating = stats.get('last', {}).get('rating', 0) - timeout_percent = stats.get('record', {}).get('timeout_percent', 0) - else: - rating = 0 - timeout_percent = 0 - - url = f'https://api.chess.com/pub/player/{username}' - - response = requests.get(url, headers=utils.headers) - - if response.status_code == 200: - profile = response.json() - - name = profile.get('name', '') - title = profile.get('title', '') - last_online = datetime.fromtimestamp(profile.get('last_online', 0), tz=UTC).strftime('%d/%m/%Y') - joined = datetime.fromtimestamp(profile.get('joined', 0), tz=UTC).strftime('%d/%m/%Y') - else: - name = '' - title = '' - last_online = '' - joined = '' - - return { - 'FIDE Title': title, - 'Username': username, - 'Name': name, - 'Sourced Club': club, - 'Daily Rating': rating, - 'Timeout Percentage': timeout_percent, - 'Last Online': last_online, - 'Joined Chess.com': joined - } - - -@utils.calculate_execution_time -def main(clubs, exclusion_club): - all_members = set() - for club in clubs: - members = fetch_club_members(club) - all_members.update((member['username'], club) for member in members) - - exclusion_members = set( - member['username'] for member in fetch_club_members(exclusion_club) - ) - eligible_members = { - username for username, club in all_members if username not in exclusion_members - } - - results = [] - for username, club in all_members: - if username in eligible_members: - member_info = fetch_member_info(username, club) - results.append(member_info) - - seen = set() - deduped_results = [] - for result in results: - if result['Username'] not in seen: - deduped_results.append(result) - seen.add(result['Username']) - - df = pd.DataFrame(deduped_results) - - file = utils.get_unique_filename('output', 'Member Prospects', 'xlsx') - df.to_excel(file, index=False, sheet_name='Member Prospects') - utils.print_line(f'Excel file created: {file}') - - -if __name__ == "__main__": - utils.init() - - exclusion_club = 'team-scotland' - clubs = os.getenv('LIST_OF_CLUBS').split(',') - - try: - main(clubs, exclusion_club) - except Exception as e: - utils.print_line(f"Error: {e}") diff --git a/src/match_strengthening_extract.py b/src/match_strengthening_extract.py deleted file mode 100644 index 2d2d20d..0000000 --- a/src/match_strengthening_extract.py +++ /dev/null @@ -1,388 +0,0 @@ -import os -from datetime import UTC, datetime - -import pandas as pd -import requests -from openpyxl.styles import Font - -import utils - -# Environment variables -MATCH_ID = os.getenv('MATCH_ID') -CLUB_REF = os.getenv('CLUB_REF') -CLUB_NAME = os.getenv('CLUB_NAME') -BASE_URL = 'https://api.chess.com/pub' - - -# Generic function to call Chess.com API -def call_chess_api(endpoint): - """ - Generic function to call Chess.com API endpoints - - Args: - endpoint (str): API endpoint path (without the base URL) - - Returns: - dict: JSON response data or empty dict if request fails - """ - url = f"{BASE_URL}/{endpoint}" - - try: - response = utils.request_handler(url, utils.headers) - return response.json() - except requests.HTTPError as e: - utils.print_line(f'API error for endpoint {endpoint}: {e}') - return {} - - -# Returns a user's stats which include daily rating and timeout percentage -def get_stats(username): - """ - Get chess statistics for a user including both daily and Chess960 ratings - - Args: - username (str): Chess.com username - - Returns: - dict: User's chess stats with both daily and chess960 ratings - """ - try: - response_data = call_chess_api(f'player/{username}/stats') - - # Get standard chess daily stats - daily_stats = response_data.get('chess_daily', {}) - daily_rating = daily_stats.get('last', {}).get('rating', 'Unrated') if daily_stats else 'Unrated' - - # Get Chess960 daily stats - chess960_stats = response_data.get('chess960_daily', {}) - chess960_rating = chess960_stats.get('last', {}).get('rating', 'Unrated') if chess960_stats else 'Unrated' - - return { - 'daily': daily_stats, - 'chess960': chess960_stats, - 'daily_rating': daily_rating, - 'chess960_rating': chess960_rating, - 'timeout_percent': daily_stats.get('record', {}).get('timeout_percent', 0) - } - - except Exception as e: - utils.print_line(f'Error getting stats for {username}: {e}') - return { - 'daily': {}, - 'chess960': {}, - 'daily_rating': 'Unrated', - 'chess960_rating': 'Unrated', - 'timeout_percent': 0 - } - - -# Returns the date of the last online activity of a user -def get_last_online(username): - """ - Get the date of user's last online activity - - Args: - username (str): Chess.com username - - Returns: - str: Formatted date of last online activity - """ - response_data = call_chess_api(f'player/{username}') - last_online = response_data.get('last_online', 0) - - return datetime.fromtimestamp(last_online, tz=UTC).strftime('%d/%m/%Y') - - -# Returns the maximum rating allowed for a given match -def get_match_rating_max(match_id): - """ - Get maximum rating allowed for a match - - Args: - match_id (str): ID of the match - - Returns: - int: Maximum rating allowed or None - """ - match_data = call_chess_api(f'match/{match_id}') - return match_data.get('settings', {}).get('max_rating', None) - - -def get_match_variant(match_id): - """ - Determine the chess variant for a given match - - Args: - match_id (str): ID of the match - - Returns: - str: 'chess960' or 'chess' based on match settings - """ - try: - match_data = call_chess_api(f'match/{match_id}') - settings = match_data.get('settings', {}) - - # Check for Chess960 indicators in the match settings - rules = settings.get('rules', '').lower() - variant = settings.get('variant', '').lower() - - # Chess960 can be indicated by 'chess960' in rules or variant fields - if 'chess960' in rules or 'chess960' in variant or '960' in rules: - return 'chess960' - - # Default to standard chess - return 'chess' - - except Exception as e: - utils.print_line(f'Warning: Could not determine match variant for {match_id}: {e}') - utils.print_line('Defaulting to standard chess') - return 'chess' - - -# Returns a list of all club members with a rating up to max_rating -def get_eligible_members(club, max_rating): - """ - Get list of club members eligible for a match based on rating - - Args: - club (str): Club reference/ID - max_rating (int): Maximum rating threshold - - Returns: - list: List of eligible members with their data - """ - club_data = call_chess_api(f'club/{club}/members') - - eligible_members = [] - all_members = club_data.get('weekly', []) + club_data.get('monthly', []) + club_data.get('all_time', []) - - for member in all_members: - username = member.get('username', '') - - stats = get_stats(username) - daily_rating = stats['daily_rating'] - - if isinstance(daily_rating, int) and daily_rating <= max_rating: - member['daily_rating'] = daily_rating - member['last_online'] = get_last_online(username) - member['timeout_percent'] = stats['timeout_percent'] - - eligible_members.append(member) - - return eligible_members - - -def get_eligible_members_by_variant(club, max_rating, variant='chess'): - """ - Get list of club members eligible for a match based on variant and rating - - Args: - club (str): Club reference/ID - max_rating (int): Maximum rating threshold - variant (str): 'chess' or 'chess960' - - Returns: - list: List of eligible members with their variant-specific data - """ - try: - club_data = call_chess_api(f'club/{club}/members') - - eligible_members = [] - all_members = club_data.get('weekly', []) + club_data.get('monthly', []) + club_data.get('all_time', []) - - # Remove duplicates by username - unique_members = {member.get('username', '').lower(): member for member in all_members} - - for _username_lower, member in unique_members.items(): - username = member.get('username', '') - - try: - stats = get_stats(username) - - # Get the appropriate rating based on variant - if variant == 'chess960': - variant_rating = stats['chess960_rating'] - # Only include if they have a numeric Chess960 rating within the limit - if isinstance(variant_rating, int) and variant_rating <= max_rating: - member['daily_rating'] = stats['daily_rating'] - member['chess960'] = variant_rating - member['last_online'] = get_last_online(username) - member['timeout_percent'] = stats['timeout_percent'] - eligible_members.append(member) - elif variant_rating == 'Unrated': - # Include unrated Chess960 players for visibility - member['daily_rating'] = stats['daily_rating'] - member['chess960'] = 'Unrated' - member['last_online'] = get_last_online(username) - member['timeout_percent'] = stats['timeout_percent'] - eligible_members.append(member) - else: - # Standard chess variant - variant_rating = stats['daily_rating'] - if isinstance(variant_rating, int) and variant_rating <= max_rating: - member['daily_rating'] = variant_rating - member['chess960'] = stats['chess960_rating'] - member['last_online'] = get_last_online(username) - member['timeout_percent'] = stats['timeout_percent'] - eligible_members.append(member) - - except Exception as e: - utils.print_line(f'Error processing member {username}: {e}') - continue - - return eligible_members - - except Exception as e: - utils.print_line(f'Error getting eligible members for variant {variant}: {e}') - return [] - - -# Returns the list of already signed up members for the match -def get_match_participants(match_id): - """ - Get list of users already signed up for a match - - Args: - match_id (str): ID of the match - - Returns: - list: Lowercase usernames of participants - """ - match_data = call_chess_api(f'match/{match_id}') - - # Optimized to use list comprehension - O(n) where n is total player count - # Find our club's team and extract all player usernames at once - club_name_lower = CLUB_NAME.lower() - all_participants = [ - player.get('username', '').lower() - for _, team_data in match_data.get('teams', {}).items() - if team_data.get('name', '').lower() == club_name_lower - for player in team_data.get('players', []) - ] - - return all_participants - - -def get_eligible_players_data(club, match_id): - """ - Collect and process data for eligible players based on match variant - - Args: - club (str): Club reference/ID - match_id (str): ID of the match - - Returns: - list: List of dictionaries containing player data with variant info - """ - try: - results = [] - - # Detect match variant first - variant = get_match_variant(match_id) - utils.print_line(f'Detected match variant: {variant.upper()}') - - # Get match data - max_rating = get_match_rating_max(match_id) - members = get_eligible_members_by_variant(club, max_rating, variant) - match_participants = get_match_participants(match_id) - - for member in members: - username = member.get('username', '') - signed_up = 'Yes' if username.lower() in match_participants else 'No' - - results.append({ - 'Username': username, - 'Daily Rating': member.get('daily_rating'), - 'Chess960 Rating': member.get('chess960'), - 'Variant': variant.upper(), - 'Last Online': member.get('last_online'), - 'Timeout Percentage': member.get('timeout_percent'), - 'Signed Up': signed_up - }) - - utils.print_line(f'Found {len(results)} eligible players for {variant.upper()} match') - return results - - except Exception as e: - utils.print_line(f'Error in get_eligible_players_data: {e}') - return [] - - -def create_excel_report(data, report_name='Match Eligibility', variant='chess'): - """ - Create an Excel report with variant-specific information - - Args: - data (list): List of dictionaries containing player data - report_name (str): Name for the Excel file - variant (str): Chess variant type for report labeling - - Returns: - str: Path to the created Excel file - """ - try: - if not data: - utils.print_line('Warning: No data provided for Excel report') - return None - - df = pd.DataFrame(data) - - file = utils.get_unique_filename('output', report_name, 'xlsx') - - # Create Excel file with pandas - with pd.ExcelWriter(file, engine='openpyxl') as writer: - sheet_name = f'{report_name} {variant.upper()} Data' - df.to_excel(writer, index=False, sheet_name=sheet_name) - - worksheet = writer.sheets[sheet_name] - - # Add hyperlinks to the Username column - for row_num, username in enumerate(df['Username'], start=2): # Start at row 2 (skip header) - cell = worksheet.cell(row=row_num, column=1) # Username is in column 1 - cell.value = username - cell.hyperlink = f'https://www.chess.com/member/{username}' - cell.font = Font(color="0000FF", underline="single") # Blue, underlined text - - utils.print_line(f"Excel file created: {file}") - return file - - except Exception as e: - utils.print_line(f'Error creating Excel report: {e}') - return None - - -@utils.calculate_execution_time -def main(club, match_id): - """ - Main function that orchestrates data collection and report generation - - Args: - club (str): Club reference/ID - match_id (str): ID of the match - """ - try: - # Detect variant first for reporting - variant = get_match_variant(match_id) - - # Get data - player_data = get_eligible_players_data(club, match_id) - - if player_data: - # Generate report with variant information - create_excel_report(player_data, 'Match Eligibility', variant) - else: - utils.print_line('No eligible players found for this match') - - except Exception as e: - utils.print_line(f'Error in main function: {e}') - - -if __name__ == "__main__": - utils.init() - - match_id = MATCH_ID - - if match_id is None: - match_id = input("Enter the match ID: ") - - main(CLUB_REF, match_id) diff --git a/src/utils/__init__.py b/src/utils/__init__.py deleted file mode 100644 index e9e8618..0000000 --- a/src/utils/__init__.py +++ /dev/null @@ -1,91 +0,0 @@ -import os -import time - -import requests -from dotenv import load_dotenv - - -def init(): - """ - Initialise environment variables from a .env file. - - Call this at the entry point of each script before accessing - environment variables. - """ - load_dotenv() - - -# Authentication headers -# Using Chrome user agent to fly low and avoid the radar :) -headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36' -} - - -def print_line(message): - """ - Print a message to stdout. - - Args: - message (str): The message to print. - - Returns: - None - """ - print(message) - - -# Decoration wrapper to calculate total execution time -def calculate_execution_time(func): - def wrapper(*args, **kwargs): - start_time = time.time() - - try: - result = func(*args, **kwargs) - except Exception as e: - print_line(f"Error in {func.__name__}: {e}") - raise - finally: - end_time = time.time() - execution_time = end_time - start_time - - print_line(f"Execution time: {execution_time:.2f} seconds") - print_line(f"Execution time: {(execution_time/60):.2f} minutes") - - return result - - return wrapper - - -# Generate a unique file name so subsequent runs don't overwrite local edits! -def get_unique_filename(folder, fname, extension): - counter = 1 - - base_dir = os.path.dirname(os.path.abspath(__file__)) - path = os.path.join(base_dir, '..', '..', folder) - - file = os.path.join(path, f"{fname}.{extension}") - - os.makedirs(path, exist_ok=True) - - while os.path.exists(file): - file = os.path.join(path, f"{fname}_{counter}.{extension}") - counter += 1 - return file - - -# Wrap external calls with retry and error handling -def request_handler(url, headers=None, retries=3, backoff_factor=0.3): - for attempt in range(retries): - try: - response = requests.get(url, headers=headers) - - response.raise_for_status() - return response - except (ConnectionError, requests.HTTPError, requests.Timeout) as e: - if attempt < retries - 1: - sleep_time = backoff_factor * (2 ** attempt) - time.sleep(sleep_time) - else: - print_line(f'Failed to fetch data from {url}') - raise e diff --git a/tests/integration/test_api_calls.py b/tests/integration/test_api_calls.py deleted file mode 100644 index a886864..0000000 --- a/tests/integration/test_api_calls.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -Integration tests for API-calling functions. - -All HTTP calls are intercepted by the `responses` library — no real network -traffic is made during these tests. -""" - -import os -from unittest.mock import patch - -import responses as resp - -# --------------------------------------------------------------------------- -# Modules under test — imported after conftest.py sets sys.path and env vars -# --------------------------------------------------------------------------- -import club_contribution_report as ccr -import generate_prospect_data as gpd -import match_strengthening_extract as mse - -BASE = "https://api.chess.com/pub" -CLUB_REF = os.environ["CLUB_REF"] # 'test-club' from conftest -CLUB_NAME = os.environ["CLUB_NAME"] # 'Test Club' from conftest - - -# --------------------------------------------------------------------------- -# club_contribution_report — get_all_club_members -# --------------------------------------------------------------------------- - - -class TestGetAllClubMembers: - @resp.activate - def test_combines_weekly_monthly_all_time(self, club_members_response): - """Members from all three categories must be merged into one list.""" - resp.add( - resp.GET, - f"{BASE}/club/{CLUB_REF}/members", - json=club_members_response, - status=200, - ) - - members = ccr.get_all_club_members() - - usernames = [m["username"] for m in members] - assert "player_weekly" in usernames - assert "player_monthly" in usernames - assert "player_alltime" in usernames - assert len(members) == 3 - - @resp.activate - def test_returns_empty_list_when_all_categories_empty(self): - resp.add( - resp.GET, - f"{BASE}/club/{CLUB_REF}/members", - json={"weekly": [], "monthly": [], "all_time": []}, - status=200, - ) - - members = ccr.get_all_club_members() - assert members == [] - - @resp.activate - def test_handles_missing_category_keys(self): - """When a category key is absent the merge must not raise.""" - resp.add( - resp.GET, - f"{BASE}/club/{CLUB_REF}/members", - json={"weekly": [{"username": "solo"}]}, # no monthly / all_time - status=200, - ) - - members = ccr.get_all_club_members() - assert len(members) == 1 - assert members[0]["username"] == "solo" - - -# --------------------------------------------------------------------------- -# club_contribution_report — get_member_daily_rating -# --------------------------------------------------------------------------- - - -class TestGetMemberDailyRating: - @resp.activate - def test_returns_rating_from_stats(self, player_stats_response): - resp.add( - resp.GET, - f"{BASE}/player/testplayer/stats", - json=player_stats_response, - status=200, - ) - - rating = ccr.get_member_daily_rating("testplayer") - assert rating == 1200 - - @resp.activate - def test_returns_na_when_chess_daily_absent(self): - """When chess_daily key is missing entirely, return 'N/A'.""" - resp.add( - resp.GET, - f"{BASE}/player/unrated/stats", - json={}, # no chess_daily - status=200, - ) - - rating = ccr.get_member_daily_rating("unrated") - assert rating == "N/A" - - @resp.activate - def test_returns_na_when_last_rating_absent(self): - resp.add( - resp.GET, - f"{BASE}/player/norating/stats", - json={"chess_daily": {"record": {"timeout_percent": 0}}}, # no 'last' - status=200, - ) - - rating = ccr.get_member_daily_rating("norating") - assert rating == "N/A" - - -# --------------------------------------------------------------------------- -# club_contribution_report — get_match_data -# --------------------------------------------------------------------------- - - -class TestGetMatchData: - MATCH_URL = f"{BASE}/match/test-match" - - @resp.activate - def test_identifies_club_as_team1(self, match_detail_response_team1): - resp.add(resp.GET, self.MATCH_URL, json=match_detail_response_team1, status=200) - - participants = ccr.get_match_data(self.MATCH_URL) - - assert "Alice" in participants - assert "Bob" in participants - # Opponents must not appear - assert "Charlie" not in participants - - @resp.activate - def test_identifies_club_as_team2(self, match_detail_response_team2): - """Club identification must work regardless of team1/team2 slot.""" - resp.add(resp.GET, self.MATCH_URL, json=match_detail_response_team2, status=200) - - participants = ccr.get_match_data(self.MATCH_URL) - - assert "Alice" in participants - assert "Charlie" not in participants - - @resp.activate - def test_extracts_results_correctly(self, match_detail_response_team1): - resp.add(resp.GET, self.MATCH_URL, json=match_detail_response_team1, status=200) - - participants = ccr.get_match_data(self.MATCH_URL) - - assert participants["Alice"]["result_white"] == "win" - assert participants["Alice"]["result_black"] == "checkmated" - - @resp.activate - def test_returns_in_progress_when_result_missing(self, match_detail_response_team1): - """Absent result field defaults to 'in progress'.""" - resp.add(resp.GET, self.MATCH_URL, json=match_detail_response_team1, status=200) - - participants = ccr.get_match_data(self.MATCH_URL) - # Bob has played_as_white='in progress' in fixture - assert participants["Bob"]["result_white"] == "in progress" - - @resp.activate - def test_returns_empty_dict_when_club_not_found(self): - """No match found for club → empty participants dict.""" - match_json = { - "teams": { - "team1": {"name": "Club A", "players": []}, - "team2": {"name": "Club B", "players": []}, - } - } - resp.add(resp.GET, self.MATCH_URL, json=match_json, status=200) - - participants = ccr.get_match_data(self.MATCH_URL) - assert participants == {} - - -# --------------------------------------------------------------------------- -# match_strengthening_extract — get_match_participants -# --------------------------------------------------------------------------- - - -class TestGetMatchParticipants: - @resp.activate - def test_returns_participants_from_clubs_team(self, match_detail_response_team1): - """Only players from Test Club's team must be returned.""" - resp.add( - resp.GET, - f"{BASE}/match/99999", - json=match_detail_response_team1, - status=200, - ) - - with patch.object(mse, "CLUB_NAME", "Test Club"): - participants = mse.get_match_participants("99999") - - assert "alice" in participants - assert "bob" in participants - assert "charlie" not in participants - - @resp.activate - def test_returns_lowercase_usernames(self, match_detail_response_team1): - """All usernames must be lowercased for case-insensitive comparison.""" - resp.add( - resp.GET, - f"{BASE}/match/99999", - json=match_detail_response_team1, - status=200, - ) - - with patch.object(mse, "CLUB_NAME", "Test Club"): - participants = mse.get_match_participants("99999") - - for username in participants: - assert username == username.lower(), f"Username '{username}' is not lowercase" - - @resp.activate - def test_returns_empty_when_club_not_in_match(self): - """Returns empty list when Test Club is not a participant.""" - match_json = { - "settings": {}, - "teams": { - "team1": {"name": "Other Club 1", "players": [{"username": "X"}]}, - "team2": {"name": "Other Club 2", "players": [{"username": "Y"}]}, - }, - } - resp.add(resp.GET, f"{BASE}/match/99999", json=match_json, status=200) - - with patch.object(mse, "CLUB_NAME", "Test Club"): - participants = mse.get_match_participants("99999") - - assert participants == [] - - -# --------------------------------------------------------------------------- -# match_strengthening_extract — get_eligible_members (rating filter) -# --------------------------------------------------------------------------- - - -class TestGetEligibleMembers: - @resp.activate - def test_filters_members_above_max_rating(self, player_profile_response): - """Members whose daily rating exceeds max_rating must be excluded.""" - club_members = { - "weekly": [{"username": "lowrated"}], - "monthly": [{"username": "highrated"}], - "all_time": [], - } - low_stats = {"chess_daily": {"last": {"rating": 1000}, "record": {"timeout_percent": 0}}, "chess960_daily": {}} - high_stats = {"chess_daily": {"last": {"rating": 1800}, "record": {"timeout_percent": 0}}, "chess960_daily": {}} - - resp.add(resp.GET, f"{BASE}/club/test-club/members", json=club_members, status=200) - resp.add(resp.GET, f"{BASE}/player/lowrated/stats", json=low_stats, status=200) - resp.add(resp.GET, f"{BASE}/player/highrated/stats", json=high_stats, status=200) - # get_last_online only called for eligible members - resp.add(resp.GET, f"{BASE}/player/lowrated", json=player_profile_response, status=200) - - eligible = mse.get_eligible_members("test-club", max_rating=1400) - - usernames = [m["username"] for m in eligible] - assert "lowrated" in usernames - assert "highrated" not in usernames - - @resp.activate - def test_unrated_members_are_excluded(self, player_profile_response): - """Unrated players (no chess_daily) must not be included.""" - club_members = {"weekly": [{"username": "unrated"}], "monthly": [], "all_time": []} - unrated_stats = {"chess960_daily": {}} # no chess_daily key - - resp.add(resp.GET, f"{BASE}/club/test-club/members", json=club_members, status=200) - resp.add(resp.GET, f"{BASE}/player/unrated/stats", json=unrated_stats, status=200) - - eligible = mse.get_eligible_members("test-club", max_rating=1400) - assert eligible == [] - - @resp.activate - def test_member_exactly_at_max_rating_is_included(self, player_profile_response): - """Rating equal to max_rating is on the boundary and must be included.""" - club_members = {"weekly": [{"username": "borderline"}], "monthly": [], "all_time": []} - borderline_stats = { - "chess_daily": {"last": {"rating": 1400}, "record": {"timeout_percent": 0}}, - "chess960_daily": {}, - } - - resp.add(resp.GET, f"{BASE}/club/test-club/members", json=club_members, status=200) - resp.add(resp.GET, f"{BASE}/player/borderline/stats", json=borderline_stats, status=200) - resp.add(resp.GET, f"{BASE}/player/borderline", json=player_profile_response, status=200) - - eligible = mse.get_eligible_members("test-club", max_rating=1400) - assert len(eligible) == 1 - assert eligible[0]["username"] == "borderline" - - -# --------------------------------------------------------------------------- -# match_strengthening_extract — get_eligible_members_by_variant (chess960) -# --------------------------------------------------------------------------- - - -class TestGetEligibleMembersByVariant: - @resp.activate - def test_chess960_uses_chess960_rating(self, player_profile_response): - """For chess960 variant, eligibility is determined by chess960_rating not daily.""" - club_members = { - "weekly": [{"username": "c960eligible"}], - "monthly": [{"username": "c960ineligible"}], - "all_time": [], - } - # c960eligible: high daily (1800) but low 960 (1050) → should be included at 1400 cap - # c960ineligible: low daily (800) but high 960 (1600) → excluded - stats_eligible = { - "chess_daily": {"last": {"rating": 1800}, "record": {"timeout_percent": 0}}, - "chess960_daily": {"last": {"rating": 1050}, "record": {"timeout_percent": 0}}, - } - stats_ineligible = { - "chess_daily": {"last": {"rating": 800}, "record": {"timeout_percent": 0}}, - "chess960_daily": {"last": {"rating": 1600}, "record": {"timeout_percent": 0}}, - } - - resp.add(resp.GET, f"{BASE}/club/test-club/members", json=club_members, status=200) - resp.add(resp.GET, f"{BASE}/player/c960eligible/stats", json=stats_eligible, status=200) - resp.add(resp.GET, f"{BASE}/player/c960ineligible/stats", json=stats_ineligible, status=200) - resp.add(resp.GET, f"{BASE}/player/c960eligible", json=player_profile_response, status=200) - - eligible = mse.get_eligible_members_by_variant("test-club", max_rating=1400, variant="chess960") - - usernames = [m["username"] for m in eligible] - assert "c960eligible" in usernames - assert "c960ineligible" not in usernames - - @resp.activate - def test_chess960_includes_unrated_960_players(self, player_profile_response): - """Unrated Chess960 players are included for visibility.""" - club_members = {"weekly": [{"username": "unrated960"}], "monthly": [], "all_time": []} - stats = { - "chess_daily": {"last": {"rating": 1200}, "record": {"timeout_percent": 0}}, - "chess960_daily": {}, # no rating → 'Unrated' - } - - resp.add(resp.GET, f"{BASE}/club/test-club/members", json=club_members, status=200) - resp.add(resp.GET, f"{BASE}/player/unrated960/stats", json=stats, status=200) - resp.add(resp.GET, f"{BASE}/player/unrated960", json=player_profile_response, status=200) - - eligible = mse.get_eligible_members_by_variant( - "test-club", max_rating=1400, variant="chess960" - ) - - usernames = [m["username"] for m in eligible] - assert "unrated960" in usernames - - -# --------------------------------------------------------------------------- -# match_strengthening_extract — get_match_variant -# --------------------------------------------------------------------------- - - -class TestGetMatchVariant: - @resp.activate - def test_detects_chess960_from_rules(self, match_detail_response_chess960): - resp.add( - resp.GET, - f"{BASE}/match/99999", - json=match_detail_response_chess960, - status=200, - ) - - variant = mse.get_match_variant("99999") - assert variant == "chess960" - - @resp.activate - def test_detects_chess960_from_variant_field(self): - match_json = {"settings": {"rules": "chess", "variant": "chess960"}} - resp.add(resp.GET, f"{BASE}/match/99999", json=match_json, status=200) - - variant = mse.get_match_variant("99999") - assert variant == "chess960" - - @resp.activate - def test_defaults_to_standard_chess(self): - match_json = {"settings": {"rules": "chess", "variant": ""}} - resp.add(resp.GET, f"{BASE}/match/99999", json=match_json, status=200) - - variant = mse.get_match_variant("99999") - assert variant == "chess" - - @resp.activate - def test_returns_chess_when_settings_absent(self): - resp.add(resp.GET, f"{BASE}/match/99999", json={}, status=200) - - variant = mse.get_match_variant("99999") - assert variant == "chess" - - -# --------------------------------------------------------------------------- -# generate_prospect_data — fetch_club_members (raw requests.get, no retry) -# --------------------------------------------------------------------------- - - -class TestFetchClubMembers: - @resp.activate - def test_returns_merged_member_list(self, club_members_response): - resp.add( - resp.GET, - f"{BASE}/club/prospect-club/members", - json=club_members_response, - status=200, - ) - - members = gpd.fetch_club_members("prospect-club") - - usernames = [m["username"] for m in members] - assert "player_weekly" in usernames - assert "player_monthly" in usernames - assert "player_alltime" in usernames - - @resp.activate - def test_returns_empty_list_on_non_200_response(self): - """Raw requests.get path returns [] on error (no retry).""" - resp.add(resp.GET, f"{BASE}/club/bad-club/members", status=500) - - members = gpd.fetch_club_members("bad-club") - assert members == [] - - @resp.activate - def test_prospect_members_exclude_existing_club_members(self, club_members_response): - """ - Verifies the exclusion logic: members already in the exclusion club - must not appear in the eligible set. - - This test exercises the filtering logic inline — the extracted - service function will provide a cleaner interface in Action 7. - """ - prospect_members_response = { - "weekly": [{"username": "new_prospect"}, {"username": "existing_member"}], - "monthly": [], - "all_time": [], - } - exclusion_response = { - "weekly": [{"username": "existing_member"}], - "monthly": [], - "all_time": [], - } - - resp.add( - resp.GET, - f"{BASE}/club/prospect-club/members", - json=prospect_members_response, - status=200, - ) - resp.add( - resp.GET, - f"{BASE}/club/my-club/members", - json=exclusion_response, - status=200, - ) - - prospect_members = gpd.fetch_club_members("prospect-club") - exclusion_members = {m["username"] for m in gpd.fetch_club_members("my-club")} - eligible = [m for m in prospect_members if m["username"] not in exclusion_members] - - eligible_usernames = [m["username"] for m in eligible] - assert "new_prospect" in eligible_usernames - assert "existing_member" not in eligible_usernames - - def test_deduplication_of_prospects_across_clubs(self): - """ - Verifies that duplicate usernames sourced from multiple clubs collapse - to a single entry. - - NOTE: The deduplication logic is currently inline in main() of - generate_prospect_data.py and cannot be cleanly unit tested without - running the full function. This test validates the dedup algorithm in - isolation using the same pattern as the production code. A proper - service-level test will be added in Action 7. - """ - # Simulate the dedup logic from generate_prospect_data.main() - results = [ - {"Username": "alice", "Sourced Club": "club-a"}, - {"Username": "bob", "Sourced Club": "club-b"}, - {"Username": "alice", "Sourced Club": "club-c"}, # duplicate - ] - - seen: set = set() - deduped: list = [] - for result in results: - if result["Username"] not in seen: - deduped.append(result) - seen.add(result["Username"]) - - assert len(deduped) == 2 - usernames = [r["Username"] for r in deduped] - assert usernames.count("alice") == 1 - assert "bob" in usernames diff --git a/tests/unit/test_calculations.py b/tests/unit/test_calculations.py index bb15d44..1c48faf 100644 --- a/tests/unit/test_calculations.py +++ b/tests/unit/test_calculations.py @@ -1,17 +1,12 @@ """ -Unit tests for pure calculation functions in club_contribution_report and utils. +Unit tests for the pure calculation functions in chesscom.domain.services. -These tests have no I/O or HTTP dependencies — they test logic only. +Originally these tested the duplicated functions in club_contribution_report.py +and utils. Now they test the canonical implementations in the services module. """ -from unittest.mock import patch -import pytest -import requests -import responses as responses_lib - -import club_contribution_report as ccr -import utils +from chesscom.domain.services import calculate_participation_percentage, calculate_win_rate # --------------------------------------------------------------------------- # calculate_participation_percentage @@ -20,27 +15,27 @@ class TestCalculateParticipationPercentage: def test_normal_participation(self): - assert ccr.calculate_participation_percentage(10, 7) == 70.0 + assert calculate_participation_percentage(10, 7) == 70.0 def test_full_participation(self): - assert ccr.calculate_participation_percentage(5, 5) == 100.0 + assert calculate_participation_percentage(5, 5) == 100.0 def test_zero_participation(self): - assert ccr.calculate_participation_percentage(10, 0) == 0.0 + assert calculate_participation_percentage(10, 0) == 0.0 def test_division_by_zero_returns_zero(self): """When no matches have been played, result must be 0 not ZeroDivisionError.""" - assert ccr.calculate_participation_percentage(0, 0) == 0 + assert calculate_participation_percentage(0, 0) == 0 def test_single_match_participated(self): - assert ccr.calculate_participation_percentage(1, 1) == 100.0 + assert calculate_participation_percentage(1, 1) == 100.0 def test_single_match_not_participated(self): - assert ccr.calculate_participation_percentage(1, 0) == 0.0 + assert calculate_participation_percentage(1, 0) == 0.0 def test_result_is_rounded_to_two_decimal_places(self): - # 1/3 * 100 = 33.333... → 33.33 - assert ccr.calculate_participation_percentage(3, 1) == 33.33 + # 1/3 * 100 = 33.333... -> 33.33 + assert calculate_participation_percentage(3, 1) == 33.33 # --------------------------------------------------------------------------- @@ -50,156 +45,30 @@ def test_result_is_rounded_to_two_decimal_places(self): class TestCalculateWinRate: def test_all_wins(self): - assert ccr.calculate_win_rate(10, 0, 0) == 100.0 + assert calculate_win_rate(10, 0, 0) == 100.0 def test_all_losses(self): - assert ccr.calculate_win_rate(0, 10, 0) == 0.0 + assert calculate_win_rate(0, 10, 0) == 0.0 def test_all_draws(self): """Win rate is 0% when all games are draws.""" - assert ccr.calculate_win_rate(0, 0, 10) == 0.0 + assert calculate_win_rate(0, 0, 10) == 0.0 def test_mixed_results(self): - # 3 wins, 2 losses, 1 draw → 3/6 = 50.0% - assert ccr.calculate_win_rate(3, 2, 1) == 50.0 + # 3 wins, 2 losses, 1 draw -> 3/6 = 50.0% + assert calculate_win_rate(3, 2, 1) == 50.0 def test_no_games_returns_zero(self): """No ZeroDivisionError when all counts are 0.""" - assert ccr.calculate_win_rate(0, 0, 0) == 0 + assert calculate_win_rate(0, 0, 0) == 0 def test_result_is_rounded_to_two_decimal_places(self): - # 1 win, 2 losses, 0 draws → 1/3 = 33.33% - assert ccr.calculate_win_rate(1, 2, 0) == 33.33 + # 1 win, 2 losses, 0 draws -> 1/3 = 33.33% + assert calculate_win_rate(1, 2, 0) == 33.33 def test_single_win(self): - assert ccr.calculate_win_rate(1, 0, 0) == 100.0 + assert calculate_win_rate(1, 0, 0) == 100.0 def test_majority_draws(self): - # 1 win, 0 losses, 9 draws → 1/10 = 10.0% - assert ccr.calculate_win_rate(1, 0, 9) == 10.0 - - -# --------------------------------------------------------------------------- -# get_unique_filename -# --------------------------------------------------------------------------- - - -class TestGetUniqueFilename: - def test_returns_base_filename_when_no_conflict(self): - """When the target file does not exist, return the plain filename.""" - with ( - patch("utils.os.path.exists", return_value=False), - patch("utils.os.makedirs"), - ): - result = utils.get_unique_filename("output", "MyReport", "xlsx") - - assert result.endswith("MyReport.xlsx") - - def test_appends_counter_when_file_exists_once(self): - """When base name exists, append _1 to avoid overwrite.""" - # First call (base) returns True, second call (_1 suffix) returns False - exists_side_effects = [True, False] - with ( - patch("utils.os.path.exists", side_effect=exists_side_effects), - patch("utils.os.makedirs"), - ): - result = utils.get_unique_filename("output", "MyReport", "xlsx") - - assert result.endswith("MyReport_1.xlsx") - - def test_increments_counter_until_free_slot(self): - """Counter increments until a free filename is found.""" - # base, _1, _2 all exist; _3 is free - exists_side_effects = [True, True, True, False] - with ( - patch("utils.os.path.exists", side_effect=exists_side_effects), - patch("utils.os.makedirs"), - ): - result = utils.get_unique_filename("output", "MyReport", "xlsx") - - assert result.endswith("MyReport_3.xlsx") - - def test_extension_is_preserved(self): - with ( - patch("utils.os.path.exists", return_value=False), - patch("utils.os.makedirs"), - ): - result = utils.get_unique_filename("output", "Report", "csv") - - assert result.endswith(".csv") - - def test_makedirs_is_called(self): - """Ensures the output directory is created if it doesn't exist.""" - with ( - patch("utils.os.path.exists", return_value=False), - patch("utils.os.makedirs") as mock_makedirs, - ): - utils.get_unique_filename("output", "Report", "xlsx") - - mock_makedirs.assert_called_once() - - -# --------------------------------------------------------------------------- -# request_handler — retry and error handling -# --------------------------------------------------------------------------- - - -class TestRequestHandler: - BASE_URL = "https://api.chess.com/pub/test-endpoint" - - @responses_lib.activate - def test_returns_response_on_success(self): - responses_lib.add(responses_lib.GET, self.BASE_URL, json={"ok": True}, status=200) - response = utils.request_handler(self.BASE_URL) - assert response.json() == {"ok": True} - - @responses_lib.activate - @patch("utils.time.sleep") - def test_retries_on_connection_error_then_succeeds(self, mock_sleep): - """Retries on ConnectionError and succeeds on the final attempt.""" - responses_lib.add(responses_lib.GET, self.BASE_URL, body=ConnectionError("Network down")) - responses_lib.add(responses_lib.GET, self.BASE_URL, body=ConnectionError("Network down")) - responses_lib.add(responses_lib.GET, self.BASE_URL, json={"ok": True}, status=200) - - response = utils.request_handler(self.BASE_URL, retries=3, backoff_factor=0.1) - - assert response.json() == {"ok": True} - assert mock_sleep.call_count == 2 # slept between attempt 1→2 and 2→3 - - @responses_lib.activate - @patch("utils.time.sleep") - def test_raises_after_max_retries_exhausted(self, mock_sleep): - """Raises the underlying exception once all retries are consumed.""" - for _ in range(3): - responses_lib.add( - responses_lib.GET, self.BASE_URL, body=ConnectionError("Network down") - ) - - with pytest.raises(ConnectionError): - utils.request_handler(self.BASE_URL, retries=3, backoff_factor=0.1) - - assert mock_sleep.call_count == 2 # sleep between attempts, not after last - - @responses_lib.activate - @patch("utils.time.sleep") - def test_raises_on_http_error_after_retries(self, mock_sleep): - """HTTPError (e.g. 429) is retried and then raised.""" - for _ in range(3): - responses_lib.add(responses_lib.GET, self.BASE_URL, status=429) - - with pytest.raises(requests.HTTPError): - utils.request_handler(self.BASE_URL, retries=3, backoff_factor=0.1) - - @responses_lib.activate - @patch("utils.time.sleep") - def test_backoff_increases_exponentially(self, mock_sleep): - """Sleep duration follows backoff_factor * 2^attempt pattern.""" - for _ in range(3): - responses_lib.add(responses_lib.GET, self.BASE_URL, body=ConnectionError()) - - with pytest.raises(ConnectionError): - utils.request_handler(self.BASE_URL, retries=3, backoff_factor=1.0) - - # Attempt 0 → sleep(1.0 * 2^0 = 1.0), Attempt 1 → sleep(1.0 * 2^1 = 2.0) - sleep_calls = [call.args[0] for call in mock_sleep.call_args_list] - assert sleep_calls == [1.0, 2.0] + # 1 win, 0 losses, 9 draws -> 1/10 = 10.0% + assert calculate_win_rate(1, 0, 9) == 10.0 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..14ea23e --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,277 @@ +"""Unit tests for chesscom.cli.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from chesscom.cli import build_parser, main +from chesscom.config import AppConfig + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_config(**overrides) -> AppConfig: + defaults = dict( + club_ref="team-scotland", + club_name="Team Scotland", + data_analysis_year=2024, + match_id="999", + prospect_clubs=["team-ireland"], + exclusion_club=None, + ) + defaults.update(overrides) + return AppConfig(**defaults) + + +def _base_patches(config=None): + """Return a context-manager stack that mocks env + client.""" + return [ + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", return_value=config or _make_config()), + patch("chesscom.cli.ChessComClient", return_value=MagicMock()), + ] + + +# =========================================================================== +# build_parser — subcommand registration +# =========================================================================== + + +class TestBuildParser: + def test_match_participation_subcommand(self): + args = build_parser().parse_args(["match-participation"]) + assert args.subcommand == "match-participation" + + def test_member_summary_subcommand(self): + args = build_parser().parse_args(["member-summary"]) + assert args.subcommand == "member-summary" + + def test_prospects_subcommand(self): + args = build_parser().parse_args(["prospects"]) + assert args.subcommand == "prospects" + + def test_match_eligibility_subcommand(self): + args = build_parser().parse_args(["match-eligibility"]) + assert args.subcommand == "match-eligibility" + + def test_match_eligibility_accepts_match_id_flag(self): + args = build_parser().parse_args(["match-eligibility", "--match-id", "42"]) + assert args.match_id == "42" + + def test_match_eligibility_match_id_defaults_to_none(self): + args = build_parser().parse_args(["match-eligibility"]) + assert args.match_id is None + + def test_no_subcommand_exits(self): + with pytest.raises(SystemExit): + build_parser().parse_args([]) + + def test_unknown_subcommand_exits(self): + with pytest.raises(SystemExit): + build_parser().parse_args(["unknown-report"]) + + +# =========================================================================== +# main() — subcommand routing +# =========================================================================== + + +class TestMainSubcommandRouting: + def _run(self, argv, mock_report_cls, config=None): + """Helper: run main(argv) with the named report class mocked.""" + mock_instance = MagicMock() + mock_instance.run.return_value = "output/test.xlsx" + mock_report_cls.return_value = mock_instance + + patches = _base_patches(config) + with patches[0], patches[1], patches[2]: + main(argv) + + return mock_report_cls, mock_instance + + def test_match_participation_runs_correct_report(self): + with patch("chesscom.cli.MatchParticipationReport") as mock_cls: + self._run(["match-participation"], mock_cls) + mock_cls.assert_called_once() + mock_cls.return_value.run.assert_called_once() + + def test_member_summary_runs_correct_report(self): + with patch("chesscom.cli.MemberSummaryReport") as mock_cls: + self._run(["member-summary"], mock_cls) + mock_cls.assert_called_once() + mock_cls.return_value.run.assert_called_once() + + def test_prospects_runs_correct_report(self): + with patch("chesscom.cli.ProspectReport") as mock_cls: + self._run(["prospects"], mock_cls) + mock_cls.assert_called_once() + mock_cls.return_value.run.assert_called_once() + + def test_match_eligibility_runs_correct_report(self): + with patch("chesscom.cli.MatchEligibilityReport") as mock_cls: + self._run(["match-eligibility"], mock_cls) + mock_cls.assert_called_once() + mock_cls.return_value.run.assert_called_once() + + +# =========================================================================== +# main() — load_dotenv and AppConfig.from_env called on every subcommand +# =========================================================================== + + +class TestMainEnvironmentLoading: + def _run_patched(self, argv): + mock_instance = MagicMock() + mock_instance.run.return_value = "output/test.xlsx" + with ( + patch("chesscom.cli.load_dotenv") as mock_ld, + patch("chesscom.cli.AppConfig.from_env", return_value=_make_config()) as mock_cfg, + patch("chesscom.cli.ChessComClient"), + patch("chesscom.cli.MemberSummaryReport", return_value=mock_instance), + patch("chesscom.cli.MatchParticipationReport", return_value=mock_instance), + patch("chesscom.cli.ProspectReport", return_value=mock_instance), + patch("chesscom.cli.MatchEligibilityReport", return_value=mock_instance), + ): + main(argv) + return mock_ld, mock_cfg + + def test_load_dotenv_called(self): + mock_ld, _ = self._run_patched(["member-summary"]) + mock_ld.assert_called_once() + + def test_app_config_from_env_called(self): + _, mock_cfg = self._run_patched(["member-summary"]) + mock_cfg.assert_called_once() + + +# =========================================================================== +# main() — --match-id flag +# =========================================================================== + + +class TestMatchIdFlag: + def test_match_id_flag_overrides_config(self): + """--match-id replaces config.match_id via dataclasses.replace.""" + config_without_id = _make_config(match_id=None) + mock_instance = MagicMock() + mock_instance.run.return_value = "output/test.xlsx" + + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", return_value=config_without_id), + patch("chesscom.cli.ChessComClient"), + patch("chesscom.cli.MatchEligibilityReport", return_value=mock_instance) as mock_cls, + ): + main(["match-eligibility", "--match-id", "12345"]) + + # The config passed to MatchEligibilityReport should have match_id="12345" + _, kwargs = mock_cls.call_args + passed_config = mock_cls.call_args[0][1] # second positional arg + assert passed_config.match_id == "12345" + + def test_env_match_id_used_when_no_flag(self): + """When --match-id absent, config.match_id from env is preserved.""" + config_with_id = _make_config(match_id="env-42") + mock_instance = MagicMock() + mock_instance.run.return_value = "output/test.xlsx" + + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", return_value=config_with_id), + patch("chesscom.cli.ChessComClient"), + patch("chesscom.cli.MatchEligibilityReport", return_value=mock_instance) as mock_cls, + ): + main(["match-eligibility"]) + + passed_config = mock_cls.call_args[0][1] + assert passed_config.match_id == "env-42" + + +# =========================================================================== +# main() — error handling +# =========================================================================== + + +class TestMainErrorHandling: + def test_config_value_error_exits_with_code_1(self): + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", side_effect=ValueError("Missing CLUB_REF")), + ): + with pytest.raises(SystemExit) as exc_info: + main(["member-summary"]) + assert exc_info.value.code == 1 + + def test_report_value_error_exits_with_code_1(self): + mock_instance = MagicMock() + mock_instance.run.side_effect = ValueError("data_analysis_year required") + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", return_value=_make_config()), + patch("chesscom.cli.ChessComClient"), + patch("chesscom.cli.MatchParticipationReport", return_value=mock_instance), + ): + with pytest.raises(SystemExit) as exc_info: + main(["match-participation"]) + assert exc_info.value.code == 1 + + def test_keyboard_interrupt_exits_with_code_130(self): + mock_instance = MagicMock() + mock_instance.run.side_effect = KeyboardInterrupt + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", return_value=_make_config()), + patch("chesscom.cli.ChessComClient"), + patch("chesscom.cli.MemberSummaryReport", return_value=mock_instance), + ): + with pytest.raises(SystemExit) as exc_info: + main(["member-summary"]) + assert exc_info.value.code == 130 + + def test_config_error_message_printed_to_stderr(self, capsys): + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", side_effect=ValueError("Missing CLUB_REF")), + ): + with pytest.raises(SystemExit): + main(["member-summary"]) + captured = capsys.readouterr() + assert "Configuration error" in captured.err + assert "Missing CLUB_REF" in captured.err + + +# =========================================================================== +# main() — output printed on success +# =========================================================================== + + +class TestMainOutput: + def test_report_path_printed_on_success(self, capsys): + mock_instance = MagicMock() + mock_instance.run.return_value = "output/My Report.xlsx" + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", return_value=_make_config()), + patch("chesscom.cli.ChessComClient"), + patch("chesscom.cli.MemberSummaryReport", return_value=mock_instance), + ): + main(["member-summary"]) + captured = capsys.readouterr() + assert "output/My Report.xlsx" in captured.out + + def test_execution_time_printed_on_success(self, capsys): + mock_instance = MagicMock() + mock_instance.run.return_value = "output/test.xlsx" + with ( + patch("chesscom.cli.load_dotenv"), + patch("chesscom.cli.AppConfig.from_env", return_value=_make_config()), + patch("chesscom.cli.ChessComClient"), + patch("chesscom.cli.MemberSummaryReport", return_value=mock_instance), + ): + main(["member-summary"]) + captured = capsys.readouterr() + assert "Execution time" in captured.out