nhl_scrabble
NHL Roster Scrabble Score Analyzer.
A tool for fetching NHL roster data and calculating Scrabble scores for player names.
1"""NHL Roster Scrabble Score Analyzer. 2 3A tool for fetching NHL roster data and calculating Scrabble scores for player names. 4""" 5 6try: 7 from nhl_scrabble._version import __version__ 8except ImportError: 9 # Fallback for development without build 10 __version__ = "0.0.0+unknown" 11 12__author__ = "Brandon Perkins" 13 14from nhl_scrabble.api.nhl_client import NHLApiClient 15from nhl_scrabble.exceptions import ValidationError 16from nhl_scrabble.scoring.scrabble import ScrabbleScorer 17 18__all__ = [ 19 "NHLApiClient", 20 "ScrabbleScorer", 21 "ValidationError", 22 "__version__", 23]
51class NHLApiClient: 52 """Client for interacting with the NHL API. 53 54 This client provides methods to fetch team standings and roster data 55 from the official NHL API with built-in retry logic, rate limiting, 56 SSRF protection, DoS prevention, and enforced SSL/TLS certificate verification. 57 58 SSL/TLS Security: 59 - Certificate verification is always enabled and cannot be disabled 60 - Uses certifi CA bundle for up-to-date certificate authorities 61 - SSL errors are caught and logged for security monitoring 62 63 DoS Prevention: 64 - Circuit breaker pattern to prevent cascading failures 65 - Connection pool limits to prevent resource exhaustion 66 - Configurable failure thresholds and timeouts 67 68 Attributes: 69 base_url: Base URL for the NHL API (SSRF-validated) 70 timeout: Request timeout in seconds 71 retries: Number of retry attempts for failed requests 72 rate_limiter: Token bucket rate limiter for API requests 73 circuit_breaker: Circuit breaker for DoS prevention 74 ca_bundle: Path to CA bundle for SSL verification (uses certifi) 75 """ 76 77 BASE_URL = "https://api-web.nhle.com/v1" # Default base URL 78 _instances: ClassVar[set[weakref.ref["NHLApiClient"]]] = set() # Track all instances 79 80 @classmethod 81 def _cleanup_callback(cls, ref: weakref.ref["NHLApiClient"]) -> None: 82 """Remove dead instance from tracking set. 83 84 Args: 85 ref: Weak reference to the instance being garbage collected. 86 """ 87 cls._instances.discard(ref) 88 89 @classmethod 90 def _cleanup_all(cls) -> None: 91 """Close all remaining open sessions at program exit (safety net).""" 92 alive_instances = [ref() for ref in cls._instances if ref() is not None] 93 if alive_instances: 94 logger.warning( 95 f"Cleaning up {len(alive_instances)} unclosed NHLApiClient session(s) at exit", 96 ) 97 for instance in alive_instances: 98 if instance and not instance._closed: # noqa: SLF001 99 instance.close() 100 101 def __init__( # noqa: PLR0913 102 self, 103 base_url: str | None = None, 104 timeout: int = 10, 105 retries: int = 3, 106 rate_limit_max_requests: int = 30, 107 rate_limit_window: float = 60.0, 108 backoff_factor: float = 2.0, 109 max_backoff: float = 30.0, 110 cache_enabled: bool = True, 111 cache_expiry: int = 3600, 112 cache_dir: str | Path | None = None, 113 verify_ssl: bool = True, 114 dos_max_connections: int = 10, 115 dos_max_per_host: int = 5, 116 dos_circuit_breaker_threshold: int = 5, 117 dos_circuit_breaker_timeout: float = 60.0, 118 ) -> None: 119 """Initialize the NHL API client. 120 121 Args: 122 base_url: Base URL for NHL API (default: https://api-web.nhle.com/v1). 123 Will be validated for SSRF protection on first request. 124 timeout: Request timeout in seconds (default: 10) 125 retries: Number of retry attempts for failed requests (default: 3) 126 rate_limit_max_requests: Maximum requests per time window (default: 30) 127 rate_limit_window: Time window for rate limiting in seconds (default: 60.0) 128 backoff_factor: Exponential backoff multiplier (default: 2.0) 129 max_backoff: Maximum backoff delay in seconds (default: 30.0) 130 cache_enabled: Enable HTTP caching (default: True) 131 cache_expiry: Cache expiration in seconds (default: 3600 = 1 hour) 132 cache_dir: Cache directory path (default: platform-specific user cache directory) 133 verify_ssl: SSL verification (must be True, cannot be disabled for security) 134 dos_max_connections: Maximum connection pool connections (default: 10) 135 dos_max_per_host: Maximum connections per host (default: 5) 136 dos_circuit_breaker_threshold: Circuit breaker failure threshold (default: 5) 137 dos_circuit_breaker_timeout: Circuit breaker timeout in seconds (default: 60.0) 138 139 Raises: 140 NHLApiError: If base_url fails SSRF protection validation or cache directory not writable 141 ValueError: If verify_ssl is False (SSL verification cannot be disabled) 142 """ 143 # Initialize state tracking FIRST (before any potential exceptions) 144 # This prevents AttributeError in __del__ if __init__ fails 145 self._closed = False 146 147 # Enforce SSL verification - cannot be disabled 148 if not verify_ssl: 149 error_msg = "SSL verification cannot be disabled for security reasons" 150 logger.error(error_msg) 151 raise ValueError(error_msg) 152 153 # Use provided base_url or fall back to class default 154 self.base_url = base_url or self.BASE_URL 155 156 self.timeout = timeout 157 self.retries = retries 158 self.backoff_factor = backoff_factor 159 self.max_backoff = max_backoff 160 self.cache_enabled = cache_enabled 161 self.cache_expiry = cache_expiry 162 163 # Initialize rate limiter 164 self.rate_limiter = RateLimiter( 165 max_requests=rate_limit_max_requests, 166 time_window=rate_limit_window, 167 ) 168 logger.debug( 169 f"Rate limiter initialized: {rate_limit_max_requests} requests per {rate_limit_window}s", 170 ) 171 172 # Initialize circuit breaker for DoS prevention 173 self.circuit_breaker = CircuitBreaker( 174 failure_threshold=dos_circuit_breaker_threshold, 175 timeout=dos_circuit_breaker_timeout, 176 expected_exception=( 177 requests.exceptions.RequestException, 178 NHLApiError, 179 ), 180 ) 181 logger.debug( 182 f"Circuit breaker initialized: threshold={dos_circuit_breaker_threshold}, " 183 f"timeout={dos_circuit_breaker_timeout}s", 184 ) 185 186 # Use certifi CA bundle for SSL verification 187 self.ca_bundle = certifi.where() 188 logger.debug(f"Using CA bundle for SSL verification: {self.ca_bundle}") 189 190 # Session can be either CachedSession or regular Session 191 self.session: requests_cache.CachedSession | requests.Session 192 if cache_enabled: 193 # Determine cache directory 194 if cache_dir is None: 195 # Use platform-specific user cache directory 196 cache_path = Path(platformdirs.user_cache_dir("nhl-scrabble", "bdperkin")) 197 else: 198 cache_path = Path(cache_dir) 199 200 # Create cache directory with permission checking 201 try: 202 cache_path.mkdir(parents=True, exist_ok=True) 203 except (OSError, PermissionError) as e: 204 logger.error(f"Cannot create cache directory {cache_path}: {e}") 205 raise NHLApiError( 206 f"Cache directory not writable: {cache_path}. " 207 f"Check permissions or specify a different cache directory " 208 f"with the cache_dir parameter.", 209 ) from e 210 211 # Verify directory is writable 212 if not os.access(cache_path, os.W_OK): 213 error_msg = ( 214 f"Cache directory not writable: {cache_path}. " 215 f"Check permissions or specify a different cache directory." 216 ) 217 logger.error(error_msg) 218 raise NHLApiError(error_msg) 219 220 # Create cached session with platform-specific path 221 cache_file = cache_path / "api_cache" 222 self.session = requests_cache.CachedSession( 223 cache_name=str(cache_file), 224 backend="sqlite", 225 expire_after=timedelta(seconds=cache_expiry), 226 allowable_codes=[200], # Only cache successful responses 227 allowable_methods=["GET"], 228 cache_control=True, # Respect Cache-Control headers 229 ) 230 logger.debug(f"HTTP caching enabled (directory: {cache_path}, expiry: {cache_expiry}s)") 231 else: 232 self.session = requests.Session() 233 logger.debug("HTTP caching disabled") 234 235 # Configure connection pool limits for DoS protection 236 adapter = HTTPAdapter( 237 pool_connections=dos_max_connections, 238 pool_maxsize=dos_max_per_host, 239 ) 240 self.session.mount("https://", adapter) 241 self.session.mount("http://", adapter) 242 logger.debug( 243 f"Connection pool configured: max_connections={dos_max_connections}, " 244 f"max_per_host={dos_max_per_host}", 245 ) 246 247 self.session.headers.update({"User-Agent": "NHL-Scrabble/2.0"}) 248 249 # Register instance for cleanup at exit (safety net) 250 self._instances.add(weakref.ref(self, self._cleanup_callback)) 251 atexit.register(self._cleanup_all) 252 253 def __del__(self) -> None: 254 """Destructor - close session if not already closed (safety net).""" 255 if not self._closed: 256 logger.warning( 257 "NHLApiClient session was not explicitly closed - cleaning up in destructor", 258 ) 259 self.close() 260 261 def _validate_request_url(self, url: str) -> None: 262 """Validate URL with SSRF protection before making request. 263 264 Args: 265 url: Full URL to validate 266 267 Raises: 268 NHLApiError: If URL fails SSRF protection validation 269 """ 270 try: 271 validate_url_for_ssrf(url) 272 except SSRFProtectionError as e: 273 logger.error( 274 "SSRF protection blocked request to %s: %s", 275 sanitize_for_logging(url), 276 sanitize_for_logging(e), 277 ) 278 raise NHLApiError(f"Request blocked by security protection: {e}") from e 279 280 def _is_url_cached(self, url: str) -> bool: 281 """Check if a URL response is cached and not expired. 282 283 Args: 284 url: The URL to check 285 286 Returns: 287 True if the URL response is cached and valid, False otherwise 288 289 Examples: 290 >>> client = NHLApiClient(cache_enabled=True) # doctest: +SKIP 291 >>> client._is_url_cached("https://api-web.nhle.com/v1/roster/TOR/current") # doctest: +SKIP 292 False # Not cached initially # doctest: +SKIP 293 """ 294 if not self.cache_enabled: 295 return False 296 297 if not hasattr(self.session, "cache"): 298 return False 299 300 try: 301 # Check if URL is in cache using has_url() method (requests-cache 1.0+) 302 if hasattr(self.session.cache, "has_url"): 303 return self.session.cache.has_url(url) # type: ignore[no-any-return] 304 305 # Fallback: check using contains() method 306 if hasattr(self.session.cache, "contains"): 307 return self.session.cache.contains(url=url) # type: ignore[no-any-return] 308 309 # If no cache checking method available, assume not cached 310 return False 311 except Exception: # noqa: BLE001 312 # If anything goes wrong checking cache, assume not cached 313 # This ensures we always apply rate limiting if uncertain 314 return False 315 316 def get_teams(self, season: str | None = None) -> dict[str, dict[str, str]]: 317 """Fetch all NHL teams with division and conference information. 318 319 This method uses the retry decorator to automatically retry on network errors. 320 The URL is validated with SSRF protection before making the request. 321 322 Args: 323 season: Optional season in format 'YYYYYYYY' (e.g., '20222023' for 2022-23). 324 If None, fetches current season data. 325 326 Returns: 327 Dictionary mapping team abbreviations to their metadata: 328 { 329 'TOR': {'division': 'Atlantic', 'conference': 'Eastern'}, 330 'MTL': {'division': 'Atlantic', 'conference': 'Eastern'}, 331 ... 332 } 333 334 Raises: 335 NHLApiConnectionError: If unable to connect to the API 336 NHLApiError: For other API errors, including SSRF protection blocks 337 338 Examples: 339 >>> client = NHLApiClient() 340 >>> teams = client.get_teams() 341 >>> "TOR" in teams 342 True 343 >>> teams_2022 = client.get_teams(season="20222023") 344 >>> "TOR" in teams_2022 345 True 346 """ 347 # Use season-specific endpoint or current season endpoint 348 endpoint = f"standings/{season}" if season else "standings/now" 349 url = f"{self.base_url}/{endpoint}" 350 351 season_desc = f"season {season}" if season else "current season" 352 logger.debug(f"Fetching NHL teams from standings endpoint for {season_desc}") 353 354 # Validate URL with SSRF protection 355 self._validate_request_url(url) 356 357 @retry( 358 max_attempts=self.retries, 359 backoff_factor=self.backoff_factor, 360 max_backoff=self.max_backoff, 361 exceptions=( 362 requests.exceptions.Timeout, 363 requests.exceptions.ConnectionError, 364 ), 365 ) 366 def _fetch_teams() -> dict[str, dict[str, str]]: 367 """Fetch teams with retry logic.""" 368 # Check if URL is cached 369 is_cached = self._is_url_cached(url) 370 371 # Only rate limit for actual API calls (not cached responses) 372 if not is_cached: 373 if logger.isEnabledFor(logging.DEBUG): 374 logger.debug("Rate limiting: acquiring token for teams request") 375 self.rate_limiter.acquire() 376 377 try: 378 response = self.session.get( 379 url, 380 timeout=self.timeout, 381 verify=self.ca_bundle, # Explicit SSL verification with certifi CA bundle 382 ) 383 384 # Handle rate limiting (429) 385 if response.status_code == 429: 386 retry_after = get_retry_after(response) 387 logger.warning(f"Rate limited (429). Waiting {retry_after}s before retry.") 388 time.sleep(retry_after) 389 # Raise to trigger retry 390 response.raise_for_status() 391 392 response.raise_for_status() 393 data = response.json() 394 395 teams_info: dict[str, dict[str, str]] = {} 396 for team in data["standings"]: 397 team_abbrev = team["teamAbbrev"]["default"] 398 team_name = team.get("teamName", {}).get("default", team_abbrev) 399 teams_info[team_abbrev] = { 400 "name": team_name, 401 "division": team.get("divisionName", "Unknown"), 402 "conference": team.get("conferenceName", "Unknown"), 403 } 404 405 logger.debug(f"Successfully fetched {len(teams_info)} teams") 406 407 # Log cache status 408 from_cache = ( 409 hasattr(response, "from_cache") 410 and isinstance(response.from_cache, bool) 411 and response.from_cache 412 ) 413 if from_cache: 414 logger.debug("Cache hit - skipped rate limiting") 415 else: 416 logger.debug("Real API request - rate limited") 417 418 return teams_info 419 420 except requests.exceptions.SSLError as e: 421 logger.error(f"SSL certificate verification failed for {url}: {e}") 422 raise NHLApiSSLError(f"SSL certificate verification failed for {url}: {e}") from e 423 except requests.exceptions.HTTPError as e: 424 logger.error(f"HTTP error while fetching teams: {e}") 425 raise NHLApiError(f"HTTP error: {e}") from e 426 except (KeyError, ValueError) as e: 427 logger.error(f"Error parsing teams response: {e}") 428 raise NHLApiError(f"Invalid API response format: {e}") from e 429 430 try: 431 # Wrap with circuit breaker for DoS prevention 432 return self.circuit_breaker.call(_fetch_teams) 433 except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: 434 # Convert to NHLApiConnectionError after retries exhausted 435 logger.error(f"Connection error after retries: {e}") 436 raise NHLApiConnectionError("Unable to connect to NHL API after retries") from e 437 438 def _sanitize_roster_player_names(self, roster_data: dict[str, Any]) -> None: 439 """Sanitize player names in roster data to prevent injection attacks. 440 441 Validates and sanitizes all player names (firstName and lastName) in the 442 roster data for all positions (forwards, defensemen, goalies). 443 444 Args: 445 roster_data: Roster data dictionary with forwards, defensemen, goalies 446 447 Raises: 448 NHLApiError: If player names contain invalid characters (potential attack) 449 450 Note: 451 Modifies roster_data in-place for efficiency 452 """ 453 for position in ("forwards", "defensemen", "goalies"): 454 if position not in roster_data: 455 continue 456 457 for player in roster_data[position]: 458 # Validate and sanitize first name 459 if ( 460 "firstName" in player 461 and isinstance(player["firstName"], dict) 462 and "default" in player["firstName"] 463 ): 464 try: 465 player["firstName"]["default"] = validate_player_name( 466 player["firstName"]["default"], 467 ) 468 except ValidationError as e: 469 logger.warning(f"Invalid player first name in API response: {e}") 470 # Use sanitized version or skip 471 player["firstName"]["default"] = "Unknown" 472 473 # Validate and sanitize last name 474 if ( 475 "lastName" in player 476 and isinstance(player["lastName"], dict) 477 and "default" in player["lastName"] 478 ): 479 try: 480 player["lastName"]["default"] = validate_player_name( 481 player["lastName"]["default"], 482 ) 483 except ValidationError as e: 484 logger.warning(f"Invalid player last name in API response: {e}") 485 # Use sanitized version or skip 486 player["lastName"]["default"] = "Unknown" 487 488 def get_team_roster( # noqa: PLR0915 489 self, 490 team_abbrev: str, 491 season: str | None = None, 492 ) -> dict[str, Any]: 493 """Fetch the roster for a specific team with input and response validation. 494 495 Validates team abbreviation before making API call and validates response 496 structure to prevent errors from malformed data. 497 498 The URL is validated with SSRF protection before making the request. 499 500 Args: 501 team_abbrev: Team abbreviation (e.g., 'TOR', 'MTL') 502 season: Optional season in format 'YYYYYYYY' (e.g., '20222023' for 2022-23). 503 If None, fetches current season roster. 504 505 Returns: 506 Dictionary containing roster data with 'forwards', 'defensemen', and 'goalies' keys 507 508 Raises: 509 ValidationError: If team abbreviation is invalid 510 NHLApiNotFoundError: If the roster is not found (404 response) 511 NHLApiConnectionError: If unable to connect to the API after all retries 512 NHLApiError: For other API errors, including SSRF protection blocks and malformed responses 513 514 Security: 515 - Validates team abbreviation to prevent injection attacks 516 - Validates response structure to prevent KeyError exceptions 517 - Sanitizes player names from API responses 518 - SSRF protection on all API requests 519 520 Examples: 521 >>> client = NHLApiClient() 522 >>> roster = client.get_team_roster("TOR") 523 >>> "forwards" in roster 524 True 525 >>> roster_2022 = client.get_team_roster("TOR", season="20222023") 526 >>> "forwards" in roster_2022 527 True 528 >>> client.get_team_roster("INVALID") 529 Traceback (most recent call last): 530 ValidationError: Team abbreviation must be 2-3 characters... 531 """ 532 # Validate team abbreviation BEFORE making API call 533 try: 534 validated_abbrev = validate_team_abbreviation(team_abbrev) 535 except ValidationError: 536 # Re-raise validation errors for consistency with other API errors 537 logger.error("Invalid team abbreviation: %s", sanitize_for_logging(team_abbrev)) 538 raise 539 540 # Use season-specific endpoint or current season endpoint 541 endpoint = ( 542 f"roster/{validated_abbrev}/{season}" 543 if season 544 else f"roster/{validated_abbrev}/current" 545 ) 546 url = f"{self.base_url}/{endpoint}" 547 548 season_desc = f"season {season}" if season else "current season" 549 if logger.isEnabledFor(logging.DEBUG): 550 logger.debug( 551 "Fetching roster for %s (%s)", 552 sanitize_for_logging(validated_abbrev), 553 sanitize_for_logging(season_desc), 554 ) 555 556 # Validate URL with SSRF protection 557 self._validate_request_url(url) 558 559 def _fetch_roster() -> dict[str, Any]: # noqa: PLR0915 560 """Fetch roster with retry logic.""" 561 for attempt in range(self.retries): 562 try: 563 # Check if URL is cached 564 is_cached = self._is_url_cached(url) 565 566 # Only rate limit for actual API calls (not cached responses) 567 if not is_cached: 568 if logger.isEnabledFor(logging.DEBUG): 569 logger.debug(f"Rate limiting: acquiring token for {team_abbrev} roster") 570 self.rate_limiter.acquire() 571 572 response = self.session.get( 573 url, 574 timeout=self.timeout, 575 verify=self.ca_bundle, # Explicit SSL verification with certifi CA bundle 576 ) 577 578 if response.status_code == 404: 579 logger.warning(f"No roster data available for {team_abbrev}") 580 raise NHLApiNotFoundError(f"Roster not found for team: {team_abbrev}") 581 582 # Handle 429 rate limiting with exponential backoff 583 if response.status_code == 429: 584 if attempt < self.retries - 1: 585 retry_after = get_retry_after(response) 586 logger.warning( 587 f"Rate limited (429) for {team_abbrev} " 588 f"(attempt {attempt + 1}/{self.retries}), " 589 f"retrying in {retry_after:.2f}s...", 590 ) 591 time.sleep(retry_after) 592 continue 593 logger.error( 594 f"Rate limited (429) for {team_abbrev} after {self.retries} attempts", 595 ) 596 raise NHLApiConnectionError( 597 f"Rate limited after {self.retries} attempts", 598 ) from None 599 600 response.raise_for_status() 601 data = response.json() 602 603 # Validate response structure 604 try: 605 validate_api_response_structure( 606 data, 607 required_keys=["forwards", "defensemen", "goalies"], 608 context=f"Team roster response for {validated_abbrev}", 609 ) 610 except ValidationError as e: 611 logger.error( 612 f"Invalid roster response structure for {validated_abbrev}: {e}", 613 ) 614 raise NHLApiError(f"Invalid API response: {e}") from e 615 616 # Sanitize player names in response 617 self._sanitize_roster_player_names(data) 618 619 if logger.isEnabledFor(logging.DEBUG): 620 logger.debug( 621 f"Successfully fetched and validated roster for {validated_abbrev}", 622 ) 623 624 # Log cache status 625 from_cache = ( 626 hasattr(response, "from_cache") 627 and isinstance(response.from_cache, bool) 628 and response.from_cache 629 ) 630 if from_cache: 631 logger.debug("Cache hit - skipped rate limiting") 632 else: 633 logger.debug("Real API request - rate limited") 634 635 return data # type: ignore[no-any-return] 636 637 except requests.exceptions.Timeout: 638 if attempt < self.retries - 1: 639 backoff_delay = _calculate_backoff_delay( 640 attempt=attempt, 641 backoff_factor=self.backoff_factor, 642 max_backoff=self.max_backoff, 643 ) 644 logger.warning( 645 f"Timeout fetching {team_abbrev} " 646 f"(attempt {attempt + 1}/{self.retries}), " 647 f"retrying in {backoff_delay:.2f}s...", 648 ) 649 time.sleep(backoff_delay) 650 else: 651 logger.error(f"Failed to fetch {team_abbrev} after {self.retries} attempts") 652 raise NHLApiConnectionError( 653 f"Request timed out after {self.retries} attempts", 654 ) from None 655 656 except requests.exceptions.SSLError as e: 657 # SSL errors should not be retried - certificate validation failure is permanent 658 logger.error(f"SSL certificate verification failed for {team_abbrev}: {e}") 659 raise NHLApiSSLError( 660 f"SSL certificate verification failed for {url}: {e}", 661 ) from e 662 663 except requests.exceptions.ConnectionError: 664 if attempt < self.retries - 1: 665 backoff_delay = _calculate_backoff_delay( 666 attempt=attempt, 667 backoff_factor=self.backoff_factor, 668 max_backoff=self.max_backoff, 669 ) 670 logger.warning( 671 f"Connection error for {team_abbrev} " 672 f"(attempt {attempt + 1}/{self.retries}), " 673 f"retrying in {backoff_delay:.2f}s...", 674 ) 675 time.sleep(backoff_delay) 676 else: 677 logger.error(f"Failed to fetch {team_abbrev} after {self.retries} attempts") 678 raise NHLApiConnectionError( 679 f"Connection failed after {self.retries} attempts", 680 ) from None 681 682 except requests.exceptions.HTTPError as e: 683 logger.error(f"HTTP error fetching {team_abbrev}: {e}") 684 raise NHLApiError(f"HTTP error: {e}") from e 685 686 # This should never be reached as all paths above either return or raise 687 raise NHLApiError("Unexpected error: retry loop completed without returning data") 688 689 # Wrap with circuit breaker for DoS prevention 690 return self.circuit_breaker.call(_fetch_roster) 691 692 def get_player_details(self, player_id: int) -> dict[str, Any]: 693 """Fetch detailed player information from NHL API. 694 695 Args: 696 player_id: NHL player ID (numeric) 697 698 Returns: 699 Player detail data including photo, birthplace, position, etc. 700 701 Raises: 702 NHLApiNotFoundError: If player not found 703 NHLApiConnectionError: If unable to connect to the API 704 NHLApiError: If API request fails 705 706 Examples: 707 >>> client = NHLApiClient() 708 >>> try: 709 ... player = client.get_player_details(8478402) # Connor McDavid 710 ... assert "playerId" in player 711 ... assert "firstName" in player 712 ... finally: 713 ... client.close() 714 """ 715 url = f"{self.base_url}/player/{player_id}/landing" 716 717 logger.debug("Fetching player details for player ID %s", sanitize_for_logging(player_id)) 718 719 # Validate URL with SSRF protection 720 self._validate_request_url(url) 721 722 @retry( 723 max_attempts=self.retries, 724 backoff_factor=self.backoff_factor, 725 max_backoff=self.max_backoff, 726 exceptions=( 727 requests.exceptions.Timeout, 728 requests.exceptions.ConnectionError, 729 ), 730 ) 731 def _fetch_player_details() -> dict[str, Any]: 732 """Fetch player details with retry logic.""" 733 # Check if URL is cached 734 is_cached = self._is_url_cached(url) 735 736 # Only rate limit for actual API calls (not cached responses) 737 if not is_cached: 738 if logger.isEnabledFor(logging.DEBUG): 739 logger.debug(f"Rate limiting: acquiring token for player {player_id}") 740 self.rate_limiter.acquire() 741 742 try: 743 response = self.session.get( 744 url, 745 timeout=self.timeout, 746 verify=self.ca_bundle, # Explicit SSL verification with certifi CA bundle 747 ) 748 749 # Handle 404 - player not found 750 if response.status_code == 404: 751 logger.warning(f"Player {player_id} not found") 752 raise NHLApiNotFoundError(f"Player {player_id} not found") 753 754 # Handle rate limiting (429) 755 if response.status_code == 429: 756 retry_after = get_retry_after(response) 757 logger.warning(f"Rate limited (429). Waiting {retry_after}s before retry.") 758 time.sleep(retry_after) 759 # Raise to trigger retry 760 response.raise_for_status() 761 762 response.raise_for_status() 763 data = response.json() 764 765 # Validate response structure 766 validate_api_response_structure( 767 data, 768 required_keys=["playerId", "firstName", "lastName"], 769 ) 770 771 logger.debug(f"Successfully fetched player details for {player_id}") 772 773 # Log cache status 774 from_cache = ( 775 hasattr(response, "from_cache") 776 and isinstance(response.from_cache, bool) 777 and response.from_cache 778 ) 779 if from_cache: 780 logger.debug("Cache hit - skipped rate limiting") 781 else: 782 logger.debug("Real API request - rate limited") 783 784 return data # type: ignore[no-any-return] 785 786 except requests.exceptions.SSLError as e: 787 logger.error(f"SSL certificate verification failed for player {player_id}: {e}") 788 raise NHLApiSSLError( 789 f"SSL certificate verification failed for {url}: {e}", 790 ) from e 791 except requests.exceptions.HTTPError as e: 792 if e.response is not None and e.response.status_code == 404: 793 raise NHLApiNotFoundError(f"Player {player_id} not found") from e 794 logger.error(f"HTTP error while fetching player {player_id}: {e}") 795 raise NHLApiError(f"HTTP error: {e}") from e 796 except (KeyError, ValueError) as e: 797 logger.error(f"Error parsing player details response: {e}") 798 raise NHLApiError(f"Invalid API response format: {e}") from e 799 800 try: 801 # Wrap with circuit breaker for DoS prevention 802 return self.circuit_breaker.call(_fetch_player_details) 803 except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: 804 # Convert to NHLApiConnectionError after retries exhausted 805 logger.error(f"Connection error after retries: {e}") 806 raise NHLApiConnectionError("Unable to connect to NHL API after retries") from e 807 808 def get_rate_limit_stats(self) -> dict[str, Any]: 809 """Get rate limiter statistics. 810 811 Returns: 812 Dictionary with rate limiter statistics including: 813 - total_requests: Total requests made 814 - total_waits: Total times waited for tokens 815 - total_wait_time: Total time spent waiting 816 - average_wait: Average wait time per wait 817 - current_tokens: Current token count 818 - max_tokens: Maximum token capacity 819 820 Examples: 821 >>> client = NHLApiClient() 822 >>> stats = client.get_rate_limit_stats() 823 >>> "total_requests" in stats 824 True 825 """ 826 return self.rate_limiter.get_stats() 827 828 def clear_cache(self) -> None: 829 """Clear the HTTP cache.""" 830 if self.cache_enabled and hasattr(self.session, "cache"): 831 self.session.cache.clear() 832 logger.info("API cache cleared") 833 else: 834 logger.debug("Cache not available or caching disabled") 835 836 def get_cache_info(self) -> dict[str, Any]: 837 """Get cache statistics and information. 838 839 Returns: 840 Dictionary with cache information: 841 - enabled (bool): Whether caching is enabled 842 - backend (str | None): Cache backend type (e.g., "sqlite") 843 - size (int | None): Number of cached responses 844 - expiry (int): Cache expiry time in seconds 845 846 Examples: 847 >>> client = NHLApiClient(cache_enabled=True) 848 >>> info = client.get_cache_info() 849 >>> print(info["enabled"]) 850 True 851 >>> print(info["backend"]) 852 'sqlite' 853 """ 854 info: dict[str, Any] = { 855 "enabled": self.cache_enabled, 856 "backend": None, 857 "size": None, 858 "expiry": self.cache_expiry, 859 } 860 861 if self.cache_enabled and hasattr(self.session, "cache"): 862 # Get backend type 863 if hasattr(self.session.cache, "db_path"): 864 info["backend"] = "sqlite" 865 866 # Get cache size (number of responses) 867 try: 868 if hasattr(self.session.cache, "responses"): 869 # requests-cache 1.0+ 870 info["size"] = len(self.session.cache.responses) 871 elif hasattr(self.session.cache, "__len__"): 872 # Fallback to __len__ if available 873 info["size"] = len(self.session.cache) 874 except Exception: # noqa: BLE001 875 # If we can't get size, leave it as None 876 logger.debug("Could not retrieve cache size") 877 878 return info 879 880 def close(self) -> None: 881 """Close the session and release resources.""" 882 if not self._closed and hasattr(self, "session"): 883 self.session.close() 884 self._closed = True 885 logger.debug("NHL API client session closed") 886 887 def __enter__(self) -> "NHLApiClient": 888 """Support context manager protocol.""" 889 return self 890 891 def __exit__( 892 self, 893 exc_type: type[BaseException] | None, 894 exc_val: BaseException | None, 895 exc_tb: types.TracebackType | None, 896 ) -> None: 897 """Close session when exiting context manager.""" 898 self.close()
Client for interacting with the NHL API.
This client provides methods to fetch team standings and roster data from the official NHL API with built-in retry logic, rate limiting, SSRF protection, DoS prevention, and enforced SSL/TLS certificate verification.
SSL/TLS Security: - Certificate verification is always enabled and cannot be disabled - Uses certifi CA bundle for up-to-date certificate authorities - SSL errors are caught and logged for security monitoring
DoS Prevention: - Circuit breaker pattern to prevent cascading failures - Connection pool limits to prevent resource exhaustion - Configurable failure thresholds and timeouts
Attributes: base_url: Base URL for the NHL API (SSRF-validated) timeout: Request timeout in seconds retries: Number of retry attempts for failed requests rate_limiter: Token bucket rate limiter for API requests circuit_breaker: Circuit breaker for DoS prevention ca_bundle: Path to CA bundle for SSL verification (uses certifi)
101 def __init__( # noqa: PLR0913 102 self, 103 base_url: str | None = None, 104 timeout: int = 10, 105 retries: int = 3, 106 rate_limit_max_requests: int = 30, 107 rate_limit_window: float = 60.0, 108 backoff_factor: float = 2.0, 109 max_backoff: float = 30.0, 110 cache_enabled: bool = True, 111 cache_expiry: int = 3600, 112 cache_dir: str | Path | None = None, 113 verify_ssl: bool = True, 114 dos_max_connections: int = 10, 115 dos_max_per_host: int = 5, 116 dos_circuit_breaker_threshold: int = 5, 117 dos_circuit_breaker_timeout: float = 60.0, 118 ) -> None: 119 """Initialize the NHL API client. 120 121 Args: 122 base_url: Base URL for NHL API (default: https://api-web.nhle.com/v1). 123 Will be validated for SSRF protection on first request. 124 timeout: Request timeout in seconds (default: 10) 125 retries: Number of retry attempts for failed requests (default: 3) 126 rate_limit_max_requests: Maximum requests per time window (default: 30) 127 rate_limit_window: Time window for rate limiting in seconds (default: 60.0) 128 backoff_factor: Exponential backoff multiplier (default: 2.0) 129 max_backoff: Maximum backoff delay in seconds (default: 30.0) 130 cache_enabled: Enable HTTP caching (default: True) 131 cache_expiry: Cache expiration in seconds (default: 3600 = 1 hour) 132 cache_dir: Cache directory path (default: platform-specific user cache directory) 133 verify_ssl: SSL verification (must be True, cannot be disabled for security) 134 dos_max_connections: Maximum connection pool connections (default: 10) 135 dos_max_per_host: Maximum connections per host (default: 5) 136 dos_circuit_breaker_threshold: Circuit breaker failure threshold (default: 5) 137 dos_circuit_breaker_timeout: Circuit breaker timeout in seconds (default: 60.0) 138 139 Raises: 140 NHLApiError: If base_url fails SSRF protection validation or cache directory not writable 141 ValueError: If verify_ssl is False (SSL verification cannot be disabled) 142 """ 143 # Initialize state tracking FIRST (before any potential exceptions) 144 # This prevents AttributeError in __del__ if __init__ fails 145 self._closed = False 146 147 # Enforce SSL verification - cannot be disabled 148 if not verify_ssl: 149 error_msg = "SSL verification cannot be disabled for security reasons" 150 logger.error(error_msg) 151 raise ValueError(error_msg) 152 153 # Use provided base_url or fall back to class default 154 self.base_url = base_url or self.BASE_URL 155 156 self.timeout = timeout 157 self.retries = retries 158 self.backoff_factor = backoff_factor 159 self.max_backoff = max_backoff 160 self.cache_enabled = cache_enabled 161 self.cache_expiry = cache_expiry 162 163 # Initialize rate limiter 164 self.rate_limiter = RateLimiter( 165 max_requests=rate_limit_max_requests, 166 time_window=rate_limit_window, 167 ) 168 logger.debug( 169 f"Rate limiter initialized: {rate_limit_max_requests} requests per {rate_limit_window}s", 170 ) 171 172 # Initialize circuit breaker for DoS prevention 173 self.circuit_breaker = CircuitBreaker( 174 failure_threshold=dos_circuit_breaker_threshold, 175 timeout=dos_circuit_breaker_timeout, 176 expected_exception=( 177 requests.exceptions.RequestException, 178 NHLApiError, 179 ), 180 ) 181 logger.debug( 182 f"Circuit breaker initialized: threshold={dos_circuit_breaker_threshold}, " 183 f"timeout={dos_circuit_breaker_timeout}s", 184 ) 185 186 # Use certifi CA bundle for SSL verification 187 self.ca_bundle = certifi.where() 188 logger.debug(f"Using CA bundle for SSL verification: {self.ca_bundle}") 189 190 # Session can be either CachedSession or regular Session 191 self.session: requests_cache.CachedSession | requests.Session 192 if cache_enabled: 193 # Determine cache directory 194 if cache_dir is None: 195 # Use platform-specific user cache directory 196 cache_path = Path(platformdirs.user_cache_dir("nhl-scrabble", "bdperkin")) 197 else: 198 cache_path = Path(cache_dir) 199 200 # Create cache directory with permission checking 201 try: 202 cache_path.mkdir(parents=True, exist_ok=True) 203 except (OSError, PermissionError) as e: 204 logger.error(f"Cannot create cache directory {cache_path}: {e}") 205 raise NHLApiError( 206 f"Cache directory not writable: {cache_path}. " 207 f"Check permissions or specify a different cache directory " 208 f"with the cache_dir parameter.", 209 ) from e 210 211 # Verify directory is writable 212 if not os.access(cache_path, os.W_OK): 213 error_msg = ( 214 f"Cache directory not writable: {cache_path}. " 215 f"Check permissions or specify a different cache directory." 216 ) 217 logger.error(error_msg) 218 raise NHLApiError(error_msg) 219 220 # Create cached session with platform-specific path 221 cache_file = cache_path / "api_cache" 222 self.session = requests_cache.CachedSession( 223 cache_name=str(cache_file), 224 backend="sqlite", 225 expire_after=timedelta(seconds=cache_expiry), 226 allowable_codes=[200], # Only cache successful responses 227 allowable_methods=["GET"], 228 cache_control=True, # Respect Cache-Control headers 229 ) 230 logger.debug(f"HTTP caching enabled (directory: {cache_path}, expiry: {cache_expiry}s)") 231 else: 232 self.session = requests.Session() 233 logger.debug("HTTP caching disabled") 234 235 # Configure connection pool limits for DoS protection 236 adapter = HTTPAdapter( 237 pool_connections=dos_max_connections, 238 pool_maxsize=dos_max_per_host, 239 ) 240 self.session.mount("https://", adapter) 241 self.session.mount("http://", adapter) 242 logger.debug( 243 f"Connection pool configured: max_connections={dos_max_connections}, " 244 f"max_per_host={dos_max_per_host}", 245 ) 246 247 self.session.headers.update({"User-Agent": "NHL-Scrabble/2.0"}) 248 249 # Register instance for cleanup at exit (safety net) 250 self._instances.add(weakref.ref(self, self._cleanup_callback)) 251 atexit.register(self._cleanup_all)
Initialize the NHL API client.
Args: base_url: Base URL for NHL API (default: https://api-web.nhle.com/v1). Will be validated for SSRF protection on first request. timeout: Request timeout in seconds (default: 10) retries: Number of retry attempts for failed requests (default: 3) rate_limit_max_requests: Maximum requests per time window (default: 30) rate_limit_window: Time window for rate limiting in seconds (default: 60.0) backoff_factor: Exponential backoff multiplier (default: 2.0) max_backoff: Maximum backoff delay in seconds (default: 30.0) cache_enabled: Enable HTTP caching (default: True) cache_expiry: Cache expiration in seconds (default: 3600 = 1 hour) cache_dir: Cache directory path (default: platform-specific user cache directory) verify_ssl: SSL verification (must be True, cannot be disabled for security) dos_max_connections: Maximum connection pool connections (default: 10) dos_max_per_host: Maximum connections per host (default: 5) dos_circuit_breaker_threshold: Circuit breaker failure threshold (default: 5) dos_circuit_breaker_timeout: Circuit breaker timeout in seconds (default: 60.0)
Raises: NHLApiError: If base_url fails SSRF protection validation or cache directory not writable ValueError: If verify_ssl is False (SSL verification cannot be disabled)
316 def get_teams(self, season: str | None = None) -> dict[str, dict[str, str]]: 317 """Fetch all NHL teams with division and conference information. 318 319 This method uses the retry decorator to automatically retry on network errors. 320 The URL is validated with SSRF protection before making the request. 321 322 Args: 323 season: Optional season in format 'YYYYYYYY' (e.g., '20222023' for 2022-23). 324 If None, fetches current season data. 325 326 Returns: 327 Dictionary mapping team abbreviations to their metadata: 328 { 329 'TOR': {'division': 'Atlantic', 'conference': 'Eastern'}, 330 'MTL': {'division': 'Atlantic', 'conference': 'Eastern'}, 331 ... 332 } 333 334 Raises: 335 NHLApiConnectionError: If unable to connect to the API 336 NHLApiError: For other API errors, including SSRF protection blocks 337 338 Examples: 339 >>> client = NHLApiClient() 340 >>> teams = client.get_teams() 341 >>> "TOR" in teams 342 True 343 >>> teams_2022 = client.get_teams(season="20222023") 344 >>> "TOR" in teams_2022 345 True 346 """ 347 # Use season-specific endpoint or current season endpoint 348 endpoint = f"standings/{season}" if season else "standings/now" 349 url = f"{self.base_url}/{endpoint}" 350 351 season_desc = f"season {season}" if season else "current season" 352 logger.debug(f"Fetching NHL teams from standings endpoint for {season_desc}") 353 354 # Validate URL with SSRF protection 355 self._validate_request_url(url) 356 357 @retry( 358 max_attempts=self.retries, 359 backoff_factor=self.backoff_factor, 360 max_backoff=self.max_backoff, 361 exceptions=( 362 requests.exceptions.Timeout, 363 requests.exceptions.ConnectionError, 364 ), 365 ) 366 def _fetch_teams() -> dict[str, dict[str, str]]: 367 """Fetch teams with retry logic.""" 368 # Check if URL is cached 369 is_cached = self._is_url_cached(url) 370 371 # Only rate limit for actual API calls (not cached responses) 372 if not is_cached: 373 if logger.isEnabledFor(logging.DEBUG): 374 logger.debug("Rate limiting: acquiring token for teams request") 375 self.rate_limiter.acquire() 376 377 try: 378 response = self.session.get( 379 url, 380 timeout=self.timeout, 381 verify=self.ca_bundle, # Explicit SSL verification with certifi CA bundle 382 ) 383 384 # Handle rate limiting (429) 385 if response.status_code == 429: 386 retry_after = get_retry_after(response) 387 logger.warning(f"Rate limited (429). Waiting {retry_after}s before retry.") 388 time.sleep(retry_after) 389 # Raise to trigger retry 390 response.raise_for_status() 391 392 response.raise_for_status() 393 data = response.json() 394 395 teams_info: dict[str, dict[str, str]] = {} 396 for team in data["standings"]: 397 team_abbrev = team["teamAbbrev"]["default"] 398 team_name = team.get("teamName", {}).get("default", team_abbrev) 399 teams_info[team_abbrev] = { 400 "name": team_name, 401 "division": team.get("divisionName", "Unknown"), 402 "conference": team.get("conferenceName", "Unknown"), 403 } 404 405 logger.debug(f"Successfully fetched {len(teams_info)} teams") 406 407 # Log cache status 408 from_cache = ( 409 hasattr(response, "from_cache") 410 and isinstance(response.from_cache, bool) 411 and response.from_cache 412 ) 413 if from_cache: 414 logger.debug("Cache hit - skipped rate limiting") 415 else: 416 logger.debug("Real API request - rate limited") 417 418 return teams_info 419 420 except requests.exceptions.SSLError as e: 421 logger.error(f"SSL certificate verification failed for {url}: {e}") 422 raise NHLApiSSLError(f"SSL certificate verification failed for {url}: {e}") from e 423 except requests.exceptions.HTTPError as e: 424 logger.error(f"HTTP error while fetching teams: {e}") 425 raise NHLApiError(f"HTTP error: {e}") from e 426 except (KeyError, ValueError) as e: 427 logger.error(f"Error parsing teams response: {e}") 428 raise NHLApiError(f"Invalid API response format: {e}") from e 429 430 try: 431 # Wrap with circuit breaker for DoS prevention 432 return self.circuit_breaker.call(_fetch_teams) 433 except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: 434 # Convert to NHLApiConnectionError after retries exhausted 435 logger.error(f"Connection error after retries: {e}") 436 raise NHLApiConnectionError("Unable to connect to NHL API after retries") from e
Fetch all NHL teams with division and conference information.
This method uses the retry decorator to automatically retry on network errors. The URL is validated with SSRF protection before making the request.
Args: season: Optional season in format 'YYYYYYYY' (e.g., '20222023' for 2022-23). If None, fetches current season data.
Returns: Dictionary mapping team abbreviations to their metadata: { 'TOR': {'division': 'Atlantic', 'conference': 'Eastern'}, 'MTL': {'division': 'Atlantic', 'conference': 'Eastern'}, ... }
Raises: NHLApiConnectionError: If unable to connect to the API NHLApiError: For other API errors, including SSRF protection blocks
Examples:
client = NHLApiClient() teams = client.get_teams() "TOR" in teams True teams_2022 = client.get_teams(season="20222023") "TOR" in teams_2022 True
488 def get_team_roster( # noqa: PLR0915 489 self, 490 team_abbrev: str, 491 season: str | None = None, 492 ) -> dict[str, Any]: 493 """Fetch the roster for a specific team with input and response validation. 494 495 Validates team abbreviation before making API call and validates response 496 structure to prevent errors from malformed data. 497 498 The URL is validated with SSRF protection before making the request. 499 500 Args: 501 team_abbrev: Team abbreviation (e.g., 'TOR', 'MTL') 502 season: Optional season in format 'YYYYYYYY' (e.g., '20222023' for 2022-23). 503 If None, fetches current season roster. 504 505 Returns: 506 Dictionary containing roster data with 'forwards', 'defensemen', and 'goalies' keys 507 508 Raises: 509 ValidationError: If team abbreviation is invalid 510 NHLApiNotFoundError: If the roster is not found (404 response) 511 NHLApiConnectionError: If unable to connect to the API after all retries 512 NHLApiError: For other API errors, including SSRF protection blocks and malformed responses 513 514 Security: 515 - Validates team abbreviation to prevent injection attacks 516 - Validates response structure to prevent KeyError exceptions 517 - Sanitizes player names from API responses 518 - SSRF protection on all API requests 519 520 Examples: 521 >>> client = NHLApiClient() 522 >>> roster = client.get_team_roster("TOR") 523 >>> "forwards" in roster 524 True 525 >>> roster_2022 = client.get_team_roster("TOR", season="20222023") 526 >>> "forwards" in roster_2022 527 True 528 >>> client.get_team_roster("INVALID") 529 Traceback (most recent call last): 530 ValidationError: Team abbreviation must be 2-3 characters... 531 """ 532 # Validate team abbreviation BEFORE making API call 533 try: 534 validated_abbrev = validate_team_abbreviation(team_abbrev) 535 except ValidationError: 536 # Re-raise validation errors for consistency with other API errors 537 logger.error("Invalid team abbreviation: %s", sanitize_for_logging(team_abbrev)) 538 raise 539 540 # Use season-specific endpoint or current season endpoint 541 endpoint = ( 542 f"roster/{validated_abbrev}/{season}" 543 if season 544 else f"roster/{validated_abbrev}/current" 545 ) 546 url = f"{self.base_url}/{endpoint}" 547 548 season_desc = f"season {season}" if season else "current season" 549 if logger.isEnabledFor(logging.DEBUG): 550 logger.debug( 551 "Fetching roster for %s (%s)", 552 sanitize_for_logging(validated_abbrev), 553 sanitize_for_logging(season_desc), 554 ) 555 556 # Validate URL with SSRF protection 557 self._validate_request_url(url) 558 559 def _fetch_roster() -> dict[str, Any]: # noqa: PLR0915 560 """Fetch roster with retry logic.""" 561 for attempt in range(self.retries): 562 try: 563 # Check if URL is cached 564 is_cached = self._is_url_cached(url) 565 566 # Only rate limit for actual API calls (not cached responses) 567 if not is_cached: 568 if logger.isEnabledFor(logging.DEBUG): 569 logger.debug(f"Rate limiting: acquiring token for {team_abbrev} roster") 570 self.rate_limiter.acquire() 571 572 response = self.session.get( 573 url, 574 timeout=self.timeout, 575 verify=self.ca_bundle, # Explicit SSL verification with certifi CA bundle 576 ) 577 578 if response.status_code == 404: 579 logger.warning(f"No roster data available for {team_abbrev}") 580 raise NHLApiNotFoundError(f"Roster not found for team: {team_abbrev}") 581 582 # Handle 429 rate limiting with exponential backoff 583 if response.status_code == 429: 584 if attempt < self.retries - 1: 585 retry_after = get_retry_after(response) 586 logger.warning( 587 f"Rate limited (429) for {team_abbrev} " 588 f"(attempt {attempt + 1}/{self.retries}), " 589 f"retrying in {retry_after:.2f}s...", 590 ) 591 time.sleep(retry_after) 592 continue 593 logger.error( 594 f"Rate limited (429) for {team_abbrev} after {self.retries} attempts", 595 ) 596 raise NHLApiConnectionError( 597 f"Rate limited after {self.retries} attempts", 598 ) from None 599 600 response.raise_for_status() 601 data = response.json() 602 603 # Validate response structure 604 try: 605 validate_api_response_structure( 606 data, 607 required_keys=["forwards", "defensemen", "goalies"], 608 context=f"Team roster response for {validated_abbrev}", 609 ) 610 except ValidationError as e: 611 logger.error( 612 f"Invalid roster response structure for {validated_abbrev}: {e}", 613 ) 614 raise NHLApiError(f"Invalid API response: {e}") from e 615 616 # Sanitize player names in response 617 self._sanitize_roster_player_names(data) 618 619 if logger.isEnabledFor(logging.DEBUG): 620 logger.debug( 621 f"Successfully fetched and validated roster for {validated_abbrev}", 622 ) 623 624 # Log cache status 625 from_cache = ( 626 hasattr(response, "from_cache") 627 and isinstance(response.from_cache, bool) 628 and response.from_cache 629 ) 630 if from_cache: 631 logger.debug("Cache hit - skipped rate limiting") 632 else: 633 logger.debug("Real API request - rate limited") 634 635 return data # type: ignore[no-any-return] 636 637 except requests.exceptions.Timeout: 638 if attempt < self.retries - 1: 639 backoff_delay = _calculate_backoff_delay( 640 attempt=attempt, 641 backoff_factor=self.backoff_factor, 642 max_backoff=self.max_backoff, 643 ) 644 logger.warning( 645 f"Timeout fetching {team_abbrev} " 646 f"(attempt {attempt + 1}/{self.retries}), " 647 f"retrying in {backoff_delay:.2f}s...", 648 ) 649 time.sleep(backoff_delay) 650 else: 651 logger.error(f"Failed to fetch {team_abbrev} after {self.retries} attempts") 652 raise NHLApiConnectionError( 653 f"Request timed out after {self.retries} attempts", 654 ) from None 655 656 except requests.exceptions.SSLError as e: 657 # SSL errors should not be retried - certificate validation failure is permanent 658 logger.error(f"SSL certificate verification failed for {team_abbrev}: {e}") 659 raise NHLApiSSLError( 660 f"SSL certificate verification failed for {url}: {e}", 661 ) from e 662 663 except requests.exceptions.ConnectionError: 664 if attempt < self.retries - 1: 665 backoff_delay = _calculate_backoff_delay( 666 attempt=attempt, 667 backoff_factor=self.backoff_factor, 668 max_backoff=self.max_backoff, 669 ) 670 logger.warning( 671 f"Connection error for {team_abbrev} " 672 f"(attempt {attempt + 1}/{self.retries}), " 673 f"retrying in {backoff_delay:.2f}s...", 674 ) 675 time.sleep(backoff_delay) 676 else: 677 logger.error(f"Failed to fetch {team_abbrev} after {self.retries} attempts") 678 raise NHLApiConnectionError( 679 f"Connection failed after {self.retries} attempts", 680 ) from None 681 682 except requests.exceptions.HTTPError as e: 683 logger.error(f"HTTP error fetching {team_abbrev}: {e}") 684 raise NHLApiError(f"HTTP error: {e}") from e 685 686 # This should never be reached as all paths above either return or raise 687 raise NHLApiError("Unexpected error: retry loop completed without returning data") 688 689 # Wrap with circuit breaker for DoS prevention 690 return self.circuit_breaker.call(_fetch_roster)
Fetch the roster for a specific team with input and response validation.
Validates team abbreviation before making API call and validates response structure to prevent errors from malformed data.
The URL is validated with SSRF protection before making the request.
Args: team_abbrev: Team abbreviation (e.g., 'TOR', 'MTL') season: Optional season in format 'YYYYYYYY' (e.g., '20222023' for 2022-23). If None, fetches current season roster.
Returns: Dictionary containing roster data with 'forwards', 'defensemen', and 'goalies' keys
Raises: ValidationError: If team abbreviation is invalid NHLApiNotFoundError: If the roster is not found (404 response) NHLApiConnectionError: If unable to connect to the API after all retries NHLApiError: For other API errors, including SSRF protection blocks and malformed responses
Security: - Validates team abbreviation to prevent injection attacks - Validates response structure to prevent KeyError exceptions - Sanitizes player names from API responses - SSRF protection on all API requests
Examples:
client = NHLApiClient() roster = client.get_team_roster("TOR") "forwards" in roster True roster_2022 = client.get_team_roster("TOR", season="20222023") "forwards" in roster_2022 True client.get_team_roster("INVALID") Traceback (most recent call last): ValidationError: Team abbreviation must be 2-3 characters...
692 def get_player_details(self, player_id: int) -> dict[str, Any]: 693 """Fetch detailed player information from NHL API. 694 695 Args: 696 player_id: NHL player ID (numeric) 697 698 Returns: 699 Player detail data including photo, birthplace, position, etc. 700 701 Raises: 702 NHLApiNotFoundError: If player not found 703 NHLApiConnectionError: If unable to connect to the API 704 NHLApiError: If API request fails 705 706 Examples: 707 >>> client = NHLApiClient() 708 >>> try: 709 ... player = client.get_player_details(8478402) # Connor McDavid 710 ... assert "playerId" in player 711 ... assert "firstName" in player 712 ... finally: 713 ... client.close() 714 """ 715 url = f"{self.base_url}/player/{player_id}/landing" 716 717 logger.debug("Fetching player details for player ID %s", sanitize_for_logging(player_id)) 718 719 # Validate URL with SSRF protection 720 self._validate_request_url(url) 721 722 @retry( 723 max_attempts=self.retries, 724 backoff_factor=self.backoff_factor, 725 max_backoff=self.max_backoff, 726 exceptions=( 727 requests.exceptions.Timeout, 728 requests.exceptions.ConnectionError, 729 ), 730 ) 731 def _fetch_player_details() -> dict[str, Any]: 732 """Fetch player details with retry logic.""" 733 # Check if URL is cached 734 is_cached = self._is_url_cached(url) 735 736 # Only rate limit for actual API calls (not cached responses) 737 if not is_cached: 738 if logger.isEnabledFor(logging.DEBUG): 739 logger.debug(f"Rate limiting: acquiring token for player {player_id}") 740 self.rate_limiter.acquire() 741 742 try: 743 response = self.session.get( 744 url, 745 timeout=self.timeout, 746 verify=self.ca_bundle, # Explicit SSL verification with certifi CA bundle 747 ) 748 749 # Handle 404 - player not found 750 if response.status_code == 404: 751 logger.warning(f"Player {player_id} not found") 752 raise NHLApiNotFoundError(f"Player {player_id} not found") 753 754 # Handle rate limiting (429) 755 if response.status_code == 429: 756 retry_after = get_retry_after(response) 757 logger.warning(f"Rate limited (429). Waiting {retry_after}s before retry.") 758 time.sleep(retry_after) 759 # Raise to trigger retry 760 response.raise_for_status() 761 762 response.raise_for_status() 763 data = response.json() 764 765 # Validate response structure 766 validate_api_response_structure( 767 data, 768 required_keys=["playerId", "firstName", "lastName"], 769 ) 770 771 logger.debug(f"Successfully fetched player details for {player_id}") 772 773 # Log cache status 774 from_cache = ( 775 hasattr(response, "from_cache") 776 and isinstance(response.from_cache, bool) 777 and response.from_cache 778 ) 779 if from_cache: 780 logger.debug("Cache hit - skipped rate limiting") 781 else: 782 logger.debug("Real API request - rate limited") 783 784 return data # type: ignore[no-any-return] 785 786 except requests.exceptions.SSLError as e: 787 logger.error(f"SSL certificate verification failed for player {player_id}: {e}") 788 raise NHLApiSSLError( 789 f"SSL certificate verification failed for {url}: {e}", 790 ) from e 791 except requests.exceptions.HTTPError as e: 792 if e.response is not None and e.response.status_code == 404: 793 raise NHLApiNotFoundError(f"Player {player_id} not found") from e 794 logger.error(f"HTTP error while fetching player {player_id}: {e}") 795 raise NHLApiError(f"HTTP error: {e}") from e 796 except (KeyError, ValueError) as e: 797 logger.error(f"Error parsing player details response: {e}") 798 raise NHLApiError(f"Invalid API response format: {e}") from e 799 800 try: 801 # Wrap with circuit breaker for DoS prevention 802 return self.circuit_breaker.call(_fetch_player_details) 803 except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: 804 # Convert to NHLApiConnectionError after retries exhausted 805 logger.error(f"Connection error after retries: {e}") 806 raise NHLApiConnectionError("Unable to connect to NHL API after retries") from e
Fetch detailed player information from NHL API.
Args: player_id: NHL player ID (numeric)
Returns: Player detail data including photo, birthplace, position, etc.
Raises: NHLApiNotFoundError: If player not found NHLApiConnectionError: If unable to connect to the API NHLApiError: If API request fails
Examples:
client = NHLApiClient() try: ... player = client.get_player_details(8478402) # Connor McDavid ... assert "playerId" in player ... assert "firstName" in player ... finally: ... client.close()
808 def get_rate_limit_stats(self) -> dict[str, Any]: 809 """Get rate limiter statistics. 810 811 Returns: 812 Dictionary with rate limiter statistics including: 813 - total_requests: Total requests made 814 - total_waits: Total times waited for tokens 815 - total_wait_time: Total time spent waiting 816 - average_wait: Average wait time per wait 817 - current_tokens: Current token count 818 - max_tokens: Maximum token capacity 819 820 Examples: 821 >>> client = NHLApiClient() 822 >>> stats = client.get_rate_limit_stats() 823 >>> "total_requests" in stats 824 True 825 """ 826 return self.rate_limiter.get_stats()
Get rate limiter statistics.
Returns: Dictionary with rate limiter statistics including: - total_requests: Total requests made - total_waits: Total times waited for tokens - total_wait_time: Total time spent waiting - average_wait: Average wait time per wait - current_tokens: Current token count - max_tokens: Maximum token capacity
Examples:
client = NHLApiClient() stats = client.get_rate_limit_stats() "total_requests" in stats True
828 def clear_cache(self) -> None: 829 """Clear the HTTP cache.""" 830 if self.cache_enabled and hasattr(self.session, "cache"): 831 self.session.cache.clear() 832 logger.info("API cache cleared") 833 else: 834 logger.debug("Cache not available or caching disabled")
Clear the HTTP cache.
836 def get_cache_info(self) -> dict[str, Any]: 837 """Get cache statistics and information. 838 839 Returns: 840 Dictionary with cache information: 841 - enabled (bool): Whether caching is enabled 842 - backend (str | None): Cache backend type (e.g., "sqlite") 843 - size (int | None): Number of cached responses 844 - expiry (int): Cache expiry time in seconds 845 846 Examples: 847 >>> client = NHLApiClient(cache_enabled=True) 848 >>> info = client.get_cache_info() 849 >>> print(info["enabled"]) 850 True 851 >>> print(info["backend"]) 852 'sqlite' 853 """ 854 info: dict[str, Any] = { 855 "enabled": self.cache_enabled, 856 "backend": None, 857 "size": None, 858 "expiry": self.cache_expiry, 859 } 860 861 if self.cache_enabled and hasattr(self.session, "cache"): 862 # Get backend type 863 if hasattr(self.session.cache, "db_path"): 864 info["backend"] = "sqlite" 865 866 # Get cache size (number of responses) 867 try: 868 if hasattr(self.session.cache, "responses"): 869 # requests-cache 1.0+ 870 info["size"] = len(self.session.cache.responses) 871 elif hasattr(self.session.cache, "__len__"): 872 # Fallback to __len__ if available 873 info["size"] = len(self.session.cache) 874 except Exception: # noqa: BLE001 875 # If we can't get size, leave it as None 876 logger.debug("Could not retrieve cache size") 877 878 return info
Get cache statistics and information.
Returns: Dictionary with cache information: - enabled (bool): Whether caching is enabled - backend (str | None): Cache backend type (e.g., "sqlite") - size (int | None): Number of cached responses - expiry (int): Cache expiry time in seconds
Examples:
client = NHLApiClient(cache_enabled=True) info = client.get_cache_info() print(info["enabled"]) True print(info["backend"]) 'sqlite'
17class ScrabbleScorer: 18 """Calculate Scrabble scores for player names using configurable letter values. 19 20 This class provides methods to calculate scores based on letter point values. 21 By default, uses standard English Scrabble values, but supports custom 22 scoring systems via the letter_values parameter. 23 24 Default letter values (standard Scrabble): 25 - 1 point: A, E, I, O, U, L, N, S, T, R 26 - 2 points: D, G 27 - 3 points: B, C, M, P 28 - 4 points: F, H, V, W, Y 29 - 5 points: K 30 - 8 points: J, X 31 - 10 points: Q, Z 32 33 Custom scoring systems can be provided via the letter_values parameter, 34 enabling alternative scoring methods (e.g., Wordle scoring, uniform values). 35 """ 36 37 LETTER_VALUES: ClassVar[dict[str, int]] = { 38 "A": 1, 39 "E": 1, 40 "I": 1, 41 "O": 1, 42 "U": 1, 43 "L": 1, 44 "N": 1, 45 "S": 1, 46 "T": 1, 47 "R": 1, 48 "D": 2, 49 "G": 2, 50 "B": 3, 51 "C": 3, 52 "M": 3, 53 "P": 3, 54 "F": 4, 55 "H": 4, 56 "V": 4, 57 "W": 4, 58 "Y": 4, 59 "K": 5, 60 "J": 8, 61 "X": 8, 62 "Q": 10, 63 "Z": 10, 64 } 65 66 def __init__(self, letter_values: dict[str, int] | None = None) -> None: 67 """Initialize the scorer with custom or default letter values. 68 69 Args: 70 letter_values: Optional custom letter-to-points mapping. 71 If None, uses standard Scrabble values. 72 73 Examples: 74 >>> # Standard Scrabble scoring 75 >>> scorer = ScrabbleScorer() 76 >>> scorer.calculate_score("ALEX") 77 11 78 79 >>> # Custom scoring (all letters worth 1 point) 80 >>> uniform_values = {chr(i): 1 for i in range(65, 91)} 81 >>> scorer = ScrabbleScorer(letter_values=uniform_values) 82 >>> scorer.calculate_score_custom("ALEX") 83 4 84 """ 85 self._letter_values = letter_values if letter_values is not None else self.LETTER_VALUES 86 logger.debug(f"ScrabbleScorer initialized with {len(self._letter_values)} letter values") 87 88 @staticmethod 89 @lru_cache(maxsize=2048) 90 def _calculate_with_values(name: str, values_tuple: tuple[tuple[str, int], ...]) -> int: 91 """Calculate score with provided letter values (cached). 92 93 This static method enables LRU caching while supporting custom letter values. 94 The letter values are passed as a hashable tuple for cache key uniqueness. 95 96 Args: 97 name: Name to score 98 values_tuple: Letter values as tuple of (letter, value) pairs 99 100 Returns: 101 Total score for the name 102 """ 103 values_dict = dict(values_tuple) 104 return sum(values_dict.get(char.upper(), 0) for char in name) 105 106 @staticmethod 107 def calculate_score(name: str) -> int: 108 """Calculate the Scrabble score for a given name using standard values. 109 110 This static method provides convenient scoring with default Scrabble letter values. 111 For custom scoring values, create a ScrabbleScorer instance and use 112 the calculate_score_custom() method. 113 114 This method uses LRU caching to avoid recomputing scores for duplicate 115 names, which significantly improves performance when processing ~700 NHL 116 players with many duplicate first/last names. 117 118 Cache size: 2048 entries (sufficient for all unique name components) 119 120 Args: 121 name: The name to score (can include spaces and special characters) 122 123 Returns: 124 The total Scrabble score (non-letter characters are worth 0 points) 125 126 Examples: 127 >>> ScrabbleScorer.calculate_score("ALEX") 128 11 129 >>> ScrabbleScorer.calculate_score("Ovechkin") 130 20 131 """ 132 # Use default Scrabble values 133 values_tuple = tuple(sorted(ScrabbleScorer.LETTER_VALUES.items())) 134 return ScrabbleScorer._calculate_with_values(name, values_tuple) 135 136 def calculate_score_custom(self, name: str) -> int: 137 """Calculate score using custom letter values configured in this instance. 138 139 Use this method when you've created a ScrabbleScorer with custom letter 140 values. For default Scrabble scoring, use the static calculate_score() method. 141 142 Args: 143 name: The name to score (can include spaces and special characters) 144 145 Returns: 146 The total score using custom letter values 147 148 Examples: 149 >>> uniform_values = {chr(i): 1 for i in range(65, 91)} 150 >>> scorer = ScrabbleScorer(letter_values=uniform_values) 151 >>> scorer.calculate_score_custom("ALEX") 152 4 153 """ 154 # Convert dict to hashable tuple for caching 155 values_tuple = tuple(sorted(self._letter_values.items())) 156 return self._calculate_with_values(name, values_tuple) 157 158 def score_player( 159 self, 160 player_data: dict[str, Any], 161 team: str, 162 division: str, 163 conference: str, 164 position_category: str = "", 165 ) -> PlayerScore: 166 """Score a player and return a PlayerScore object. 167 168 Uses custom letter values if configured, otherwise uses default Scrabble values. 169 170 Args: 171 player_data: Dictionary with 'firstName', 'lastName', and optionally 'id', 172 'birthCity', 'birthStateProvince', 'birthCountry', 'positionCode' keys 173 team: Team abbreviation 174 division: Division name 175 conference: Conference name 176 position_category: Position category from API roster grouping 177 ('forwards', 'defensemen', or 'goalies') 178 179 Returns: 180 PlayerScore object with all scoring and birthplace information 181 182 Examples: 183 >>> scorer = ScrabbleScorer() 184 >>> player = { 185 ... "id": 8478402, 186 ... "firstName": {"default": "Connor"}, 187 ... "lastName": {"default": "McDavid"}, 188 ... "positionCode": "C" 189 ... } 190 >>> result = scorer.score_player(player, "EDM", "Pacific", "Western", "forwards") 191 >>> result.full_score 192 24 193 >>> result.player_id 194 8478402 195 >>> result.position 196 'Center' 197 >>> result.position_type 198 'Forward' 199 """ 200 first_name = player_data["firstName"]["default"] 201 last_name = player_data["lastName"]["default"] 202 full_name = f"{first_name} {last_name}" 203 204 # Extract player ID from NHL API data (0 if not provided for backwards compatibility) 205 player_id = player_data.get("id", 0) 206 207 # Extract birthplace information (may not be available for all players) 208 birth_city = ( 209 player_data.get("birthCity", {}).get("default", "") 210 if isinstance(player_data.get("birthCity"), dict) 211 else player_data.get("birthCity", "") 212 ) 213 birth_state = ( 214 player_data.get("birthStateProvince", {}).get("default", "") 215 if isinstance(player_data.get("birthStateProvince"), dict) 216 else player_data.get("birthStateProvince", "") 217 ) 218 birth_country_code = player_data.get("birthCountry", "") 219 220 # Format birthplace as "City, State" or just "City" if no state 221 if birth_city and birth_state: 222 birthplace = f"{birth_city}, {birth_state}" 223 elif birth_city: 224 birthplace = birth_city 225 else: 226 birthplace = "" 227 228 # Convert country code to full name 229 nationality = get_country_name(birth_country_code) if birth_country_code else "" 230 231 # Extract position information 232 position_code = player_data.get("positionCode", "") 233 position = get_position_name(position_code) if position_code else "" 234 position_type = get_position_type(position_code) if position_code else "" 235 236 # If position_type is Unknown but we have a category, infer from category 237 if position_type == "Unknown" and position_category: 238 # Map position category to position type 239 position_type_map = { 240 "forwards": "Forward", 241 "defensemen": "Defense", 242 "goalies": "Goalie", 243 } 244 position_type = position_type_map.get(position_category, "") 245 246 # Use custom scoring if custom values are set 247 if self._letter_values is not self.LETTER_VALUES: 248 first_score = self.calculate_score_custom(first_name) 249 last_score = self.calculate_score_custom(last_name) 250 else: 251 first_score = self.calculate_score(first_name) 252 last_score = self.calculate_score(last_name) 253 254 full_score = first_score + last_score 255 256 return PlayerScore( 257 first_name=first_name, 258 last_name=last_name, 259 full_name=full_name, 260 first_score=first_score, 261 last_score=last_score, 262 full_score=full_score, 263 team=team, 264 division=division, 265 conference=conference, 266 player_id=player_id, 267 birthplace=birthplace, 268 birth_country=birth_country_code, 269 nationality=nationality, 270 position_code=position_code, 271 position=position, 272 position_type=position_type, 273 ) 274 275 @staticmethod 276 def get_cache_info() -> dict[str, int]: 277 """Get cache statistics for the score calculation cache. 278 279 Returns: 280 Dictionary with cache statistics: 281 - hits: Number of cache hits 282 - misses: Number of cache misses 283 - maxsize: Maximum cache size 284 - currsize: Current cache size 285 286 Examples: 287 >>> info = ScrabbleScorer.get_cache_info() 288 >>> info['maxsize'] 289 2048 290 """ 291 cache_info = ScrabbleScorer._calculate_with_values.cache_info() 292 return { 293 "hits": cache_info.hits, 294 "misses": cache_info.misses, 295 "maxsize": cache_info.maxsize or 0, 296 "currsize": cache_info.currsize, 297 } 298 299 @staticmethod 300 def log_cache_stats() -> None: 301 """Log cache statistics for monitoring and performance analysis. 302 303 Logs hit rate, total calls, and cache utilization at INFO level. 304 """ 305 stats = ScrabbleScorer.get_cache_info() 306 total_calls = stats["hits"] + stats["misses"] 307 308 if total_calls > 0: 309 hit_rate = (stats["hits"] / total_calls) * 100 310 utilization = ( 311 (stats["currsize"] / stats["maxsize"]) * 100 if stats["maxsize"] > 0 else 0 312 ) 313 314 logger.debug( 315 "Scrabble scoring cache stats: " 316 f"hits={stats['hits']}, " 317 f"misses={stats['misses']}, " 318 f"hit_rate={hit_rate:.1f}%, " 319 f"size={stats['currsize']}/{stats['maxsize']} " 320 f"({utilization:.1f}% full)", 321 ) 322 else: 323 logger.debug("Scrabble scoring cache: No calls yet") 324 325 @staticmethod 326 def clear_cache() -> None: 327 """Clear the score calculation cache. 328 329 Useful for testing or when memory needs to be freed. 330 """ 331 ScrabbleScorer._calculate_with_values.cache_clear() 332 logger.debug("Scrabble scoring cache cleared")
Calculate Scrabble scores for player names using configurable letter values.
This class provides methods to calculate scores based on letter point values. By default, uses standard English Scrabble values, but supports custom scoring systems via the letter_values parameter.
Default letter values (standard Scrabble): - 1 point: A, E, I, O, U, L, N, S, T, R - 2 points: D, G - 3 points: B, C, M, P - 4 points: F, H, V, W, Y - 5 points: K - 8 points: J, X - 10 points: Q, Z
Custom scoring systems can be provided via the letter_values parameter, enabling alternative scoring methods (e.g., Wordle scoring, uniform values).
66 def __init__(self, letter_values: dict[str, int] | None = None) -> None: 67 """Initialize the scorer with custom or default letter values. 68 69 Args: 70 letter_values: Optional custom letter-to-points mapping. 71 If None, uses standard Scrabble values. 72 73 Examples: 74 >>> # Standard Scrabble scoring 75 >>> scorer = ScrabbleScorer() 76 >>> scorer.calculate_score("ALEX") 77 11 78 79 >>> # Custom scoring (all letters worth 1 point) 80 >>> uniform_values = {chr(i): 1 for i in range(65, 91)} 81 >>> scorer = ScrabbleScorer(letter_values=uniform_values) 82 >>> scorer.calculate_score_custom("ALEX") 83 4 84 """ 85 self._letter_values = letter_values if letter_values is not None else self.LETTER_VALUES 86 logger.debug(f"ScrabbleScorer initialized with {len(self._letter_values)} letter values")
Initialize the scorer with custom or default letter values.
Args: letter_values: Optional custom letter-to-points mapping. If None, uses standard Scrabble values.
Examples:
Standard Scrabble scoring
scorer = ScrabbleScorer() scorer.calculate_score("ALEX") 11
>>> # Custom scoring (all letters worth 1 point) >>> uniform_values = {chr(i): 1 for i in range(65, 91)} >>> scorer = ScrabbleScorer(letter_values=uniform_values) >>> scorer.calculate_score_custom("ALEX") 4
106 @staticmethod 107 def calculate_score(name: str) -> int: 108 """Calculate the Scrabble score for a given name using standard values. 109 110 This static method provides convenient scoring with default Scrabble letter values. 111 For custom scoring values, create a ScrabbleScorer instance and use 112 the calculate_score_custom() method. 113 114 This method uses LRU caching to avoid recomputing scores for duplicate 115 names, which significantly improves performance when processing ~700 NHL 116 players with many duplicate first/last names. 117 118 Cache size: 2048 entries (sufficient for all unique name components) 119 120 Args: 121 name: The name to score (can include spaces and special characters) 122 123 Returns: 124 The total Scrabble score (non-letter characters are worth 0 points) 125 126 Examples: 127 >>> ScrabbleScorer.calculate_score("ALEX") 128 11 129 >>> ScrabbleScorer.calculate_score("Ovechkin") 130 20 131 """ 132 # Use default Scrabble values 133 values_tuple = tuple(sorted(ScrabbleScorer.LETTER_VALUES.items())) 134 return ScrabbleScorer._calculate_with_values(name, values_tuple)
Calculate the Scrabble score for a given name using standard values.
This static method provides convenient scoring with default Scrabble letter values. For custom scoring values, create a ScrabbleScorer instance and use the calculate_score_custom() method.
This method uses LRU caching to avoid recomputing scores for duplicate names, which significantly improves performance when processing ~700 NHL players with many duplicate first/last names.
Cache size: 2048 entries (sufficient for all unique name components)
Args: name: The name to score (can include spaces and special characters)
Returns: The total Scrabble score (non-letter characters are worth 0 points)
Examples:
ScrabbleScorer.calculate_score("ALEX") 11 ScrabbleScorer.calculate_score("Ovechkin") 20
136 def calculate_score_custom(self, name: str) -> int: 137 """Calculate score using custom letter values configured in this instance. 138 139 Use this method when you've created a ScrabbleScorer with custom letter 140 values. For default Scrabble scoring, use the static calculate_score() method. 141 142 Args: 143 name: The name to score (can include spaces and special characters) 144 145 Returns: 146 The total score using custom letter values 147 148 Examples: 149 >>> uniform_values = {chr(i): 1 for i in range(65, 91)} 150 >>> scorer = ScrabbleScorer(letter_values=uniform_values) 151 >>> scorer.calculate_score_custom("ALEX") 152 4 153 """ 154 # Convert dict to hashable tuple for caching 155 values_tuple = tuple(sorted(self._letter_values.items())) 156 return self._calculate_with_values(name, values_tuple)
Calculate score using custom letter values configured in this instance.
Use this method when you've created a ScrabbleScorer with custom letter values. For default Scrabble scoring, use the static calculate_score() method.
Args: name: The name to score (can include spaces and special characters)
Returns: The total score using custom letter values
Examples:
uniform_values = {chr(i): 1 for i in range(65, 91)} scorer = ScrabbleScorer(letter_values=uniform_values) scorer.calculate_score_custom("ALEX") 4
158 def score_player( 159 self, 160 player_data: dict[str, Any], 161 team: str, 162 division: str, 163 conference: str, 164 position_category: str = "", 165 ) -> PlayerScore: 166 """Score a player and return a PlayerScore object. 167 168 Uses custom letter values if configured, otherwise uses default Scrabble values. 169 170 Args: 171 player_data: Dictionary with 'firstName', 'lastName', and optionally 'id', 172 'birthCity', 'birthStateProvince', 'birthCountry', 'positionCode' keys 173 team: Team abbreviation 174 division: Division name 175 conference: Conference name 176 position_category: Position category from API roster grouping 177 ('forwards', 'defensemen', or 'goalies') 178 179 Returns: 180 PlayerScore object with all scoring and birthplace information 181 182 Examples: 183 >>> scorer = ScrabbleScorer() 184 >>> player = { 185 ... "id": 8478402, 186 ... "firstName": {"default": "Connor"}, 187 ... "lastName": {"default": "McDavid"}, 188 ... "positionCode": "C" 189 ... } 190 >>> result = scorer.score_player(player, "EDM", "Pacific", "Western", "forwards") 191 >>> result.full_score 192 24 193 >>> result.player_id 194 8478402 195 >>> result.position 196 'Center' 197 >>> result.position_type 198 'Forward' 199 """ 200 first_name = player_data["firstName"]["default"] 201 last_name = player_data["lastName"]["default"] 202 full_name = f"{first_name} {last_name}" 203 204 # Extract player ID from NHL API data (0 if not provided for backwards compatibility) 205 player_id = player_data.get("id", 0) 206 207 # Extract birthplace information (may not be available for all players) 208 birth_city = ( 209 player_data.get("birthCity", {}).get("default", "") 210 if isinstance(player_data.get("birthCity"), dict) 211 else player_data.get("birthCity", "") 212 ) 213 birth_state = ( 214 player_data.get("birthStateProvince", {}).get("default", "") 215 if isinstance(player_data.get("birthStateProvince"), dict) 216 else player_data.get("birthStateProvince", "") 217 ) 218 birth_country_code = player_data.get("birthCountry", "") 219 220 # Format birthplace as "City, State" or just "City" if no state 221 if birth_city and birth_state: 222 birthplace = f"{birth_city}, {birth_state}" 223 elif birth_city: 224 birthplace = birth_city 225 else: 226 birthplace = "" 227 228 # Convert country code to full name 229 nationality = get_country_name(birth_country_code) if birth_country_code else "" 230 231 # Extract position information 232 position_code = player_data.get("positionCode", "") 233 position = get_position_name(position_code) if position_code else "" 234 position_type = get_position_type(position_code) if position_code else "" 235 236 # If position_type is Unknown but we have a category, infer from category 237 if position_type == "Unknown" and position_category: 238 # Map position category to position type 239 position_type_map = { 240 "forwards": "Forward", 241 "defensemen": "Defense", 242 "goalies": "Goalie", 243 } 244 position_type = position_type_map.get(position_category, "") 245 246 # Use custom scoring if custom values are set 247 if self._letter_values is not self.LETTER_VALUES: 248 first_score = self.calculate_score_custom(first_name) 249 last_score = self.calculate_score_custom(last_name) 250 else: 251 first_score = self.calculate_score(first_name) 252 last_score = self.calculate_score(last_name) 253 254 full_score = first_score + last_score 255 256 return PlayerScore( 257 first_name=first_name, 258 last_name=last_name, 259 full_name=full_name, 260 first_score=first_score, 261 last_score=last_score, 262 full_score=full_score, 263 team=team, 264 division=division, 265 conference=conference, 266 player_id=player_id, 267 birthplace=birthplace, 268 birth_country=birth_country_code, 269 nationality=nationality, 270 position_code=position_code, 271 position=position, 272 position_type=position_type, 273 )
Score a player and return a PlayerScore object.
Uses custom letter values if configured, otherwise uses default Scrabble values.
Args: player_data: Dictionary with 'firstName', 'lastName', and optionally 'id', 'birthCity', 'birthStateProvince', 'birthCountry', 'positionCode' keys team: Team abbreviation division: Division name conference: Conference name position_category: Position category from API roster grouping ('forwards', 'defensemen', or 'goalies')
Returns: PlayerScore object with all scoring and birthplace information
Examples:
scorer = ScrabbleScorer() player = { ... "id": 8478402, ... "firstName": {"default": "Connor"}, ... "lastName": {"default": "McDavid"}, ... "positionCode": "C" ... } result = scorer.score_player(player, "EDM", "Pacific", "Western", "forwards") result.full_score 24 result.player_id 8478402 result.position 'Center' result.position_type 'Forward'
275 @staticmethod 276 def get_cache_info() -> dict[str, int]: 277 """Get cache statistics for the score calculation cache. 278 279 Returns: 280 Dictionary with cache statistics: 281 - hits: Number of cache hits 282 - misses: Number of cache misses 283 - maxsize: Maximum cache size 284 - currsize: Current cache size 285 286 Examples: 287 >>> info = ScrabbleScorer.get_cache_info() 288 >>> info['maxsize'] 289 2048 290 """ 291 cache_info = ScrabbleScorer._calculate_with_values.cache_info() 292 return { 293 "hits": cache_info.hits, 294 "misses": cache_info.misses, 295 "maxsize": cache_info.maxsize or 0, 296 "currsize": cache_info.currsize, 297 }
Get cache statistics for the score calculation cache.
Returns: Dictionary with cache statistics: - hits: Number of cache hits - misses: Number of cache misses - maxsize: Maximum cache size - currsize: Current cache size
Examples:
info = ScrabbleScorer.get_cache_info() info['maxsize'] 2048
299 @staticmethod 300 def log_cache_stats() -> None: 301 """Log cache statistics for monitoring and performance analysis. 302 303 Logs hit rate, total calls, and cache utilization at INFO level. 304 """ 305 stats = ScrabbleScorer.get_cache_info() 306 total_calls = stats["hits"] + stats["misses"] 307 308 if total_calls > 0: 309 hit_rate = (stats["hits"] / total_calls) * 100 310 utilization = ( 311 (stats["currsize"] / stats["maxsize"]) * 100 if stats["maxsize"] > 0 else 0 312 ) 313 314 logger.debug( 315 "Scrabble scoring cache stats: " 316 f"hits={stats['hits']}, " 317 f"misses={stats['misses']}, " 318 f"hit_rate={hit_rate:.1f}%, " 319 f"size={stats['currsize']}/{stats['maxsize']} " 320 f"({utilization:.1f}% full)", 321 ) 322 else: 323 logger.debug("Scrabble scoring cache: No calls yet")
Log cache statistics for monitoring and performance analysis.
Logs hit rate, total calls, and cache utilization at INFO level.
325 @staticmethod 326 def clear_cache() -> None: 327 """Clear the score calculation cache. 328 329 Useful for testing or when memory needs to be freed. 330 """ 331 ScrabbleScorer._calculate_with_values.cache_clear() 332 logger.debug("Scrabble scoring cache cleared")
Clear the score calculation cache.
Useful for testing or when memory needs to be freed.
48class ValidationError(NHLScrabbleError, ValueError): 49 """Raised when input validation fails. 50 51 This exception is raised when user input or configuration values fail 52 validation checks. It provides clear, actionable error messages to help 53 users correct their input. 54 55 Inherits from both NHLScrabbleError and ValueError for backward compatibility 56 with code that catches ValueError. 57 58 Examples: 59 >>> raise ValidationError("top_players must be between 1 and 100, got 999") 60 Traceback (most recent call last): 61 ValidationError: top_players must be between 1 and 100, got 999 62 """
Raised when input validation fails.
This exception is raised when user input or configuration values fail validation checks. It provides clear, actionable error messages to help users correct their input.
Inherits from both NHLScrabbleError and ValueError for backward compatibility with code that catches ValueError.
Examples:
raise ValidationError("top_players must be between 1 and 100, got 999") Traceback (most recent call last): ValidationError: top_players must be between 1 and 100, got 999