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]
class NHLApiClient:
 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)

NHLApiClient( base_url: str | None = None, timeout: int = 10, retries: int = 3, rate_limit_max_requests: int = 30, rate_limit_window: float = 60.0, backoff_factor: float = 2.0, max_backoff: float = 30.0, cache_enabled: bool = True, cache_expiry: int = 3600, cache_dir: str | pathlib.Path | None = None, verify_ssl: bool = True, dos_max_connections: int = 10, dos_max_per_host: int = 5, dos_circuit_breaker_threshold: int = 5, dos_circuit_breaker_timeout: float = 60.0)
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)

BASE_URL = 'https://api-web.nhle.com/v1'
base_url
timeout
retries
backoff_factor
max_backoff
cache_enabled
cache_expiry
rate_limiter
circuit_breaker
ca_bundle
session: requests_cache.session.CachedSession | requests.sessions.Session
def get_teams(self, season: str | None = None) -> dict[str, dict[str, str]]:
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

def get_team_roster( self, team_abbrev: str, season: str | None = None) -> dict[str, typing.Any]:
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...

def get_player_details(self, player_id: int) -> dict[str, typing.Any]:
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()

def get_rate_limit_stats(self) -> dict[str, typing.Any]:
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

def clear_cache(self) -> None:
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.

def get_cache_info(self) -> dict[str, typing.Any]:
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'

def close(self) -> None:
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")

Close the session and release resources.

class ScrabbleScorer:
 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).

ScrabbleScorer(letter_values: dict[str, int] | None = None)
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
LETTER_VALUES: ClassVar[dict[str, int]] = {'A': 1, 'E': 1, 'I': 1, 'O': 1, 'U': 1, 'L': 1, 'N': 1, 'S': 1, 'T': 1, 'R': 1, 'D': 2, 'G': 2, 'B': 3, 'C': 3, 'M': 3, 'P': 3, 'F': 4, 'H': 4, 'V': 4, 'W': 4, 'Y': 4, 'K': 5, 'J': 8, 'X': 8, 'Q': 10, 'Z': 10}
@staticmethod
def calculate_score(name: str) -> int:
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

def calculate_score_custom(self, name: str) -> int:
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

def score_player( self, player_data: dict[str, typing.Any], team: str, division: str, conference: str, position_category: str = '') -> nhl_scrabble.models.player.PlayerScore:
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'

@staticmethod
def get_cache_info() -> dict[str, int]:
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

@staticmethod
def log_cache_stats() -> None:
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.

@staticmethod
def clear_cache() -> None:
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.

class ValidationError(nhl_scrabble.exceptions.NHLScrabbleError, builtins.ValueError):
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

__version__ = '0.0.18.dev73+g956e24566'