diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3ceb9ad..df8ea21 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,6 +7,8 @@ on: - 'docs/**' - 'mkdocs.yml' - '.github/workflows/docs.yml' + - 'pyproject.toml' + - 'poetry.lock' workflow_dispatch: permissions: @@ -26,23 +28,28 @@ jobs: with: python-version: '3.11' + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.7.1 + virtualenvs-create: true + virtualenvs-in-project: true + - name: Cache dependencies uses: actions/cache@v4 with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-docs-${{ hashFiles('**/pyproject.toml') }} + path: .venv + key: venv-${{ runner.os }}-3.11-${{ hashFiles('**/poetry.lock') }}-docs restore-keys: | - ${{ runner.os }}-pip-docs- + venv-${{ runner.os }}-3.11-docs- - name: Install dependencies - run: | - pip install mkdocs-material - pip install pymdown-extensions + run: poetry install --only docs --no-root - name: Configure Git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Deploy documentation - run: mkdocs gh-deploy --force --clean --verbose + - name: Deploy documentation to GitHub Pages + run: poetry run mkdocs gh-deploy --force --clean --verbose diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..d47a6d1 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,48 @@ +# Read the Docs configuration file for ifpa-api-python +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required: Specify version 2 for new features and compatibility +version: 2 + +# Build environment configuration +build: + os: ubuntu-24.04 # Latest LTS for best package support + tools: + python: "3.11" # Match project minimum Python version + + # Custom build steps for Poetry dependency management + jobs: + post_create_environment: + # Install Poetry in ReadTheDocs environment + - pip install poetry==1.7.1 + # Disable Poetry virtual environment creation (RTD manages its own) + - poetry config virtualenvs.create false + + post_install: + # Install only documentation dependencies using Poetry + # --only docs: include the docs dependency group + # --no-root: don't install the ifpa-api package itself (not needed for docs) + - poetry install --only docs --no-root + +# MkDocs configuration +mkdocs: + configuration: mkdocs.yml + # ReadTheDocs will automatically run: mkdocs build --site-dir $READTHEDOCS_OUTPUT/html + +# Optional: Search configuration +search: + ranking: + # Boost API reference pages in search results + "api-client-reference/*": 2 + # Boost getting started content + "getting-started/*": 3 + ignore: + # Don't index generated/build artifacts + - "site/*" + +# Optional: Formats to build (in addition to HTML) +# formats: +# - pdf +# - epub + +# Note: We don't specify python.install here because Poetry handles all installations diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb73b4..8c0b151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,152 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2025-11-22 + +### Added + +**ReadTheDocs Integration** - Professional documentation hosting and infrastructure: + +- Created `.readthedocs.yaml` configuration file for automated documentation builds +- Reorganized Poetry dependencies with dedicated `docs` group for MkDocs and mkdocs-material +- Updated GitHub Actions CI workflow (`.github/workflows/ci.yml`) to use Poetry for docs builds +- Documentation now available at https://ifpa-api.readthedocs.io/ +- Improved documentation discoverability and accessibility for the Python community + +**Type-Safe Enums for Rankings and Tournaments** - Enhanced type safety for improved developer experience: + +- `RankingDivision` enum for Rankings resource: + - `RankingDivision.OPEN` - Open division rankings + - `RankingDivision.WOMEN` - Women's division rankings + - Used in `RankingsClient.women()` for `tournament_type` parameter +- `TournamentSearchType` enum for Tournament search: + - `TournamentSearchType.OPEN` - Open division tournaments + - `TournamentSearchType.WOMEN` - Women's division tournaments + - `TournamentSearchType.YOUTH` - Youth division tournaments + - `TournamentSearchType.LEAGUE` - League format tournaments + - Used in `TournamentQueryBuilder.tournament_type()` method +- Both enums maintain full backward compatibility with string parameters via union types (`Enum | str`) + +**Usage Example:** +```python +from ifpa_api import IfpaClient, RankingDivision, TournamentSearchType + +client = IfpaClient() + +# Rankings with type-safe enum +rankings = client.rankings.women( + tournament_type=RankingDivision.OPEN, + count=50 +) + +# Tournament search with type-safe enum +tournaments = (client.tournament.search("Championship") + .tournament_type(TournamentSearchType.WOMEN) + .country("US") + .get()) + +# Strings still work (backward compatible) +rankings = client.rankings.women(tournament_type="OPEN", count=50) +``` + +**Benefits:** +- Type safety: Catch invalid values at development time with mypy +- IDE autocomplete: Discover available division types +- Self-documenting: Clear what values are valid +- No breaking changes: Strings still work for existing code + +### Changed + +- Moved MkDocs and mkdocs-material from `dev` dependency group to dedicated optional `docs` group +- Updated project documentation URL in `pyproject.toml` to point to ReadTheDocs +- Enhanced documentation with comprehensive enum usage examples across Rankings and Tournaments resources +- Updated `CLAUDE.md` with ReadTheDocs integration and new enum documentation + +### Documentation + +- Added type-safe enum examples to Rankings resource documentation +- Added tournament type filtering examples to Tournaments resource documentation +- Updated installation guide with current version references +- Improved code examples throughout documentation to demonstrate new enums + +## [0.4.0] - 2025-11-21 + +### Added + +**Type-Safe Enums for Stats Parameters** - Added three new enums for improved type safety and IDE autocomplete: + +- `StatsRankType.OPEN` and `StatsRankType.WOMEN` for rank_type parameters (used in 8 endpoints) +- `SystemCode.OPEN` and `SystemCode.WOMEN` for system_code parameter in overall() endpoint +- `MajorTournament.YES` and `MajorTournament.NO` for major parameter in lucrative_tournaments() endpoint +- All enums maintain full backwards compatibility with string parameters +- Union types (`Enum | str`) ensure existing code continues to work without changes +- Enums are exported from main package: `from ifpa_api import StatsRankType, SystemCode, MajorTournament` + +**Usage Example:** +```python +from ifpa_api import IfpaClient, StatsRankType, MajorTournament + +client = IfpaClient() + +# Use enums for type safety (recommended) +stats = client.stats.country_players(rank_type=StatsRankType.WOMEN) +tournaments = client.stats.lucrative_tournaments( + rank_type=StatsRankType.WOMEN, + major=MajorTournament.YES +) + +# Strings still work (backwards compatible) +stats = client.stats.country_players(rank_type="WOMEN") +``` + +**Benefits:** +- ✅ Type safety: Catch typos at development time +- ✅ IDE autocomplete: Discover available values +- ✅ Self-documenting: Clear what values are valid +- ✅ No breaking changes: Strings still work + +**Stats Resource (NEW)** - 10 operational endpoints for IFPA statistical data: + +The Stats API was documented in v0.1.0 as returning 404 errors from the live API. All endpoints are now operational and fully implemented with comprehensive testing. + +**Geographic Statistics:** +- `StatsClient.country_players()` - Player count statistics by country with OPEN/WOMEN ranking support +- `StatsClient.state_players()` - Player count statistics by state/province (North America) +- `StatsClient.state_tournaments()` - Tournament counts and WPPR point totals by state + +**Historical Trends:** +- `StatsClient.events_by_year()` - Yearly tournament, player, and country participation trends +- `StatsClient.players_by_year()` - Player retention statistics across consecutive years + +**Tournament Rankings:** +- `StatsClient.largest_tournaments()` - Top 25 tournaments by player count +- `StatsClient.lucrative_tournaments()` - Top 25 tournaments by WPPR value with major/non-major filtering + +**Period-Based Analytics:** +- `StatsClient.points_given_period()` - Top point earners for a custom date range +- `StatsClient.events_attended_period()` - Most active players by tournament attendance for a date range + +**System Statistics:** +- `StatsClient.overall()` - Comprehensive IFPA system metrics including total players, active players, tournament counts, and age distribution + +**Implementation Details:** +- 22 new Pydantic models in `src/ifpa_api/models/stats.py` +- String-to-int coercion for count fields (API returns strings like "47101") +- Decimal type for point values to preserve full precision +- Comprehensive docstrings with practical examples for all endpoints +- Full integration with existing error handling and validation system + +**Known API Issues:** +- `overall()` endpoint system_code=WOMEN parameter appears to be ignored by the API (returns OPEN data regardless) + +### Testing +- 1333 lines of unit tests with inline mocked responses +- 642 lines of integration tests against live API +- 15 new tests specifically for enum type validation and backwards compatibility +- Stats-specific test fixtures for date ranges and validation helpers +- All tests passing +- Maintained 99% code coverage + ## [0.3.0] - 2025-11-18 ### Breaking Changes - Field Name Alignment @@ -589,7 +735,9 @@ profile = client.player(123).details() - `GET /reference/countries` - List of countries - `GET /reference/states` - List of states/provinces -[Unreleased]: https://github.com/johnsosoka/ifpa-api-python/compare/v0.2.2...HEAD +[Unreleased]: https://github.com/johnsosoka/ifpa-api-python/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/johnsosoka/ifpa-api-python/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/johnsosoka/ifpa-api-python/compare/v0.2.2...v0.3.0 [0.2.2]: https://github.com/johnsosoka/ifpa-api-python/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/johnsosoka/ifpa-api-python/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/johnsosoka/ifpa-api-python/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index c2b8735..b2d2804 100644 --- a/README.md +++ b/README.md @@ -6,92 +6,55 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![CI](https://github.com/johnsosoka/ifpa-api-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/johnsosoka/ifpa-api-python/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/johnsosoka/ifpa-api-python/branch/main/graph/badge.svg)](https://codecov.io/gh/johnsosoka/ifpa-api-python) -[![Documentation](https://img.shields.io/badge/docs-mkdocs-blue.svg)](https://johnsosoka.github.io/ifpa-api-python/) +[![Documentation](https://readthedocs.org/projects/ifpa-api/badge/?version=latest)](https://ifpa-api.readthedocs.io/en/latest/?badge=latest) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) **Note**: This is an unofficial client library, not affiliated with or endorsed by IFPA. A typed Python client for the [IFPA (International Flipper Pinball Association) API](https://api.ifpapinball.com/). Access player rankings, tournament data, and statistics through a clean, type-safe Python interface with Pydantic validation. -**Complete documentation**: https://johnsosoka.github.io/ifpa-api-python/ +**Complete documentation**: https://ifpa-api.readthedocs.io/ -## What's New in 0.3.0 +## What's New in 0.4.0 -**Quality of Life Improvements** - Enhanced debugging, pagination, and error handling: +**ReadTheDocs Integration** - Professional documentation hosting: -```python -from ifpa_api import ( - IfpaClient, - IfpaApiError, - SeriesPlayerNotFoundError, - TournamentNotLeagueError, -) - -# 1. Enhanced Error Messages - Full request context in exceptions -try: - player = client.player(99999).details() -except IfpaApiError as e: - print(e) # "[404] Resource not found (URL: https://api.ifpapinball.com/player/99999)" - print(e.request_url) # Direct access to URL - print(e.request_params) # Direct access to query parameters - -# 2. Pagination Helpers - Automatic pagination for large result sets -for player in client.player.query().country("US").iterate(limit=100): - print(f"{player.first_name} {player.last_name}") +The complete documentation is now hosted on ReadTheDocs for improved accessibility and discoverability. Visit https://ifpa-api.readthedocs.io/ for guides, API reference, and examples. -all_players = client.player.query().country("US").state("WA").get_all() - -# 3. Semantic Exceptions - Clear, specific errors for common scenarios -try: - card = client.series("PAPA").player_card(12345, "OH") -except SeriesPlayerNotFoundError as e: - print(f"Player {e.player_id} has no results in {e.series_code}") - -# 4. Better Validation Messages - Helpful hints for validation errors -# Input error now shows: "Invalid parameter 'country': Input should be a valid string -# Hint: Country code should be a 2-letter string like 'US' or 'CA'" -``` - -**Query Builder Pattern** - Build complex queries with a fluent, type-safe interface: +**Type-Safe Enums** - Enhanced type safety for rankings and tournaments: ```python -# Immutable query builders allow reuse -us_players = client.player.query().country("US") -wa_results = us_players.state("WA").limit(25).get() -or_results = us_players.state("OR").limit(25).get() # Base query unchanged +from ifpa_api import IfpaClient, RankingDivision, TournamentSearchType -# Chain filters naturally -tournaments = client.tournament.query("Championship") \ - .country("US") \ - .date_range("2024-01-01", "2024-12-31") \ - .limit(50) \ - .get() +client = IfpaClient(api_key="your-api-key") -# Filter without search terms -results = client.player.query() \ - .tournament("PAPA") \ - .position(1) \ - .get() -``` - -**Unified Callable Pattern** - All resources now follow the same intuitive pattern: +# Rankings with type-safe enum +rankings = client.rankings.women( + tournament_type=RankingDivision.OPEN, + count=50 +) -```python -# Individual resource access -player = client.player(12345).details() -director = client.director(456).details() -tournament = client.tournament(789).details() +# Tournament search with type-safe enum +tournaments = (client.tournament.search("Championship") + .tournament_type(TournamentSearchType.WOMEN) + .country("US") + .get()) -# Collection queries -players = client.player.query("John").get() -directors = client.director.query("Josh").get() -tournaments = client.tournament.query("PAPA").get() +# IDE autocomplete shows available options +# - RankingDivision.OPEN / RankingDivision.WOMEN +# - TournamentSearchType.OPEN / WOMEN / YOUTH / LEAGUE -# Series operations -standings = client.series("NACS").standings() +# Strings still work (backward compatible) +rankings = client.rankings.women(tournament_type="OPEN", count=50) ``` -**Breaking Changes**: Users upgrading from 0.2.x should review the [Migration Guide](#migration-from-02x). +**Benefits:** +- Type safety: Catch invalid values at development time +- IDE autocomplete: Discover available division types +- Self-documenting: Clear what values are valid +- No breaking changes: Existing code continues to work + +This release includes stats resource with 10 endpoints, type-safe enums for stats parameters, enhanced error messages, pagination helpers, and query builder pattern. See [CHANGELOG](CHANGELOG.md) for details. ## Features @@ -101,7 +64,7 @@ standings = client.series("NACS").standings() - **Automatic Pagination**: Memory-efficient iteration with `.iterate()` and `.get_all()` - **Enhanced Error Context**: All exceptions include request URLs and parameters for debugging - **Semantic Exceptions**: Domain-specific errors (PlayersNeverMetError, SeriesPlayerNotFoundError, etc.) -- **36 API Endpoints**: Complete coverage of IFPA API v2.1 across 6 resources +- **46 API Endpoints**: Complete coverage of IFPA API v2.1 across 7 resources - **99% Test Coverage**: Comprehensive unit and integration tests - **Context Manager Support**: Automatic resource cleanup - **Clear Error Handling**: Structured exception hierarchy for different failure modes @@ -267,6 +230,53 @@ all_series = client.series.list() active_only = client.series.list(active=True) ``` +### Stats + +```python +from ifpa_api import IfpaClient, StatsRankType, SystemCode, MajorTournament + +client = IfpaClient() + +# Get overall IFPA statistics +stats = client.stats.overall(system_code=SystemCode.OPEN) +print(f"Active players: {stats.stats.active_player_count:,}") +print(f"Tournaments this year: {stats.stats.tournament_count_this_year:,}") + +# Get top point earners for a time period +points = client.stats.points_given_period( + rank_type=StatsRankType.OPEN, + start_date="2024-01-01", + end_date="2024-12-31", + limit=25 +) +for player in points.stats[:10]: + print(f"{player.first_name} {player.last_name}: {player.wppr_points} pts") + +# Get largest tournaments +tournaments = client.stats.largest_tournaments( + rank_type=StatsRankType.OPEN, + country_code="US" +) +for tourney in tournaments.stats[:10]: + print(f"{tourney.tournament_name}: {tourney.player_count} players") + +# Get player counts by country (women's rankings) +country_stats = client.stats.country_players(rank_type=StatsRankType.WOMEN) +for country in country_stats.stats[:10]: + print(f"{country.country_name}: {country.player_count:,} players") + +# Get most active players in a time period +active_players = client.stats.events_attended_period( + rank_type=StatsRankType.OPEN, + start_date="2024-01-01", + end_date="2024-12-31", + country_code="US", + limit=25 +) +``` + +**Type Safety**: Stats methods accept typed enums (e.g., `StatsRankType.WOMEN`) or strings for backwards compatibility. + ### Reference Data ```python @@ -489,7 +499,7 @@ poetry run pre-commit run --all-files ## Resources -- **Documentation**: https://johnsosoka.github.io/ifpa-api-python/ +- **Documentation**: https://ifpa-api.readthedocs.io/ - **PyPI Package**: https://pypi.org/project/ifpa-api/ - **GitHub Repository**: https://github.com/johnsosoka/ifpa-api-python - **Issue Tracker**: https://github.com/johnsosoka/ifpa-api-python/issues diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index c0ed3ba..df0fdd0 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -24,7 +24,7 @@ After installation, verify that the package is installed correctly: ```python import ifpa_api -print(ifpa_api.__version__) # Should print: 0.3.0 +print(ifpa_api.__version__) # Should print: 0.4.0 ``` You can also check the installed version using pip: diff --git a/docs/index.md b/docs/index.md index 3913c02..d9008c8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,7 +106,7 @@ Requires Python 3.11 or higher. ## API Resources -The client provides access to 36 of 46 IFPA API endpoints: +The client provides access to 46 of 46 IFPA API endpoints: | Resource | Description | Endpoints | |----------|-------------|-----------| @@ -115,6 +115,7 @@ The client provides access to 36 of 46 IFPA API endpoints: | **Rankings** | WPPR, women, youth, pro, and custom rankings | 9 | | **Tournaments** | Search tournaments, view results and details | 6 | | **Series** | Tournament series standings and statistics | 8 | +| **Stats** | Geographic distributions, historical trends, overall IFPA statistics | 10 | | **Reference** | Countries and states lookup | 2 | ## Getting Help @@ -142,6 +143,7 @@ The client provides access to 36 of 46 IFPA API endpoints: - [Tournaments](resources/tournaments.md) - Tournament data - [Rankings](resources/rankings.md) - WPPR rankings - [Series](resources/series.md) - Series standings +- [Stats](resources/stats.md) - Statistical data and trends ## License diff --git a/docs/resources/rankings.md b/docs/resources/rankings.md index e333b1f..932fc62 100644 --- a/docs/resources/rankings.md +++ b/docs/resources/rankings.md @@ -66,27 +66,33 @@ next_page: RankingsResponse = client.rankings.wppr( Access women's ranking system with options for open or women-only tournaments: ```python -from ifpa_api import IfpaClient +from ifpa_api import IfpaClient, RankingDivision from ifpa_api.models.rankings import RankingsResponse client: IfpaClient = IfpaClient() -# Get women's rankings from all tournaments +# Get women's rankings from all tournaments (using enum - preferred) rankings: RankingsResponse = client.rankings.women( - tournament_type="OPEN", + tournament_type=RankingDivision.OPEN, start_pos=0, count=50 ) -# Get women's rankings from women-only tournaments +# Get women's rankings from women-only tournaments (using enum) women_only: RankingsResponse = client.rankings.women( - tournament_type="WOMEN", + tournament_type=RankingDivision.WOMEN, + count=50 +) + +# Using strings (backwards compatible) +rankings_str: RankingsResponse = client.rankings.women( + tournament_type="OPEN", count=50 ) # Filter by country us_women: RankingsResponse = client.rankings.women( - tournament_type="OPEN", + tournament_type=RankingDivision.OPEN, country="US", count=100 ) @@ -101,7 +107,7 @@ for entry in rankings.rankings: | Parameter | Type | Description | |-----------|------|-------------| -| `tournament_type` | `str` | Tournament filter: "OPEN" for all tournaments, "WOMEN" for women-only (default: "OPEN") | +| `tournament_type` | `RankingDivision \| str` | Tournament filter: `RankingDivision.OPEN` or "OPEN" for all tournaments, `RankingDivision.WOMEN` or "WOMEN" for women-only (default: "OPEN") | | `start_pos` | `int \| str` | Starting position for pagination | | `count` | `int \| str` | Number of results to return (max 250) | | `country` | `str` | Filter by country code | @@ -181,21 +187,27 @@ for entry in virtual.rankings: Access professional circuit rankings for open and women's divisions: ```python -from ifpa_api import IfpaClient +from ifpa_api import IfpaClient, RankingDivision from ifpa_api.models.rankings import RankingsResponse client: IfpaClient = IfpaClient() -# Get open division pro rankings +# Get open division pro rankings (using enum - preferred) pro: RankingsResponse = client.rankings.pro( - ranking_system="OPEN", + ranking_system=RankingDivision.OPEN, start_pos=0, count=50 ) -# Get women's division pro rankings +# Get women's division pro rankings (using enum) women_pro: RankingsResponse = client.rankings.pro( - ranking_system="WOMEN", + ranking_system=RankingDivision.WOMEN, + count=50 +) + +# Using strings (backwards compatible) +pro_str: RankingsResponse = client.rankings.pro( + ranking_system="OPEN", count=50 ) @@ -210,7 +222,7 @@ for entry in pro.rankings: | Parameter | Type | Description | |-----------|------|-------------| -| `ranking_system` | `str` | Division filter: "OPEN" for open division, "WOMEN" for women's division (default: "OPEN") | +| `ranking_system` | `RankingDivision \| str` | Division filter: `RankingDivision.OPEN` or "OPEN" for open division, `RankingDivision.WOMEN` or "WOMEN" for women's division (default: "OPEN") | | `start_pos` | `int` | Starting position for pagination | | `count` | `int` | Number of results to return (max 250) | diff --git a/docs/resources/stats.md b/docs/resources/stats.md new file mode 100644 index 0000000..5a8368b --- /dev/null +++ b/docs/resources/stats.md @@ -0,0 +1,785 @@ +# Stats + +The Stats resource provides access to IFPA statistical data including geographic player distributions, tournament metrics, historical trends, player activity over time periods, and overall IFPA system statistics. + +## Quick Example + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import CountryPlayersResponse + +client: IfpaClient = IfpaClient() + +# Get player counts by country (women's rankings) +stats: CountryPlayersResponse = client.stats.country_players(rank_type=StatsRankType.WOMEN) +``` + +!!! tip "Type Safety with Enums" + As of v0.4.0, all stats methods accept typed enums for improved type safety and autocomplete: + + - `StatsRankType.OPEN` and `StatsRankType.WOMEN` for rank_type parameters + - `SystemCode.OPEN` and `SystemCode.WOMEN` for system_code parameter + - `MajorTournament.YES` and `MajorTournament.NO` for major parameter + + String parameters still work for backwards compatibility: + ```python + # Both work - enum is recommended + stats = client.stats.country_players(rank_type=StatsRankType.WOMEN) + stats = client.stats.country_players(rank_type="WOMEN") + ``` + +## Geographic Statistics + +### Player Counts by Country + +Get comprehensive player count statistics for all countries with registered IFPA players: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import CountryPlayersResponse + +client: IfpaClient = IfpaClient() + +# Get all countries with player counts (OPEN rankings) +stats: CountryPlayersResponse = client.stats.country_players(rank_type=StatsRankType.OPEN) + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"\nTop 5 Countries by Player Count:") + +for country in stats.stats[:5]: + print(f"{country.stats_rank}. {country.country_name} ({country.country_code})") + print(f" {country.player_count} players") + +# Output: +# Type: Players by Country +# Rank Type: OPEN +# +# Top 5 Countries by Player Count: +# 1. United States (US) +# 47101 players +# 2. Canada (CA) +# 6890 players +# 3. United Kingdom (GB) +# 5021 players +``` + +You can also query women's rankings: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import CountryPlayersResponse + +client: IfpaClient = IfpaClient() + +# Get women's rankings by country +women_stats: CountryPlayersResponse = client.stats.country_players(rank_type=StatsRankType.WOMEN) + +for country in women_stats.stats[:5]: + print(f"{country.country_name}: {country.player_count} players") +``` + +### Player Counts by State/Province + +Get player count statistics for North American states and provinces: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import StatePlayersResponse + +client: IfpaClient = IfpaClient() + +# Get all states with player counts +stats: StatePlayersResponse = client.stats.state_players(rank_type=StatsRankType.OPEN) + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"\nTop 5 States/Provinces by Player Count:") + +for state in stats.stats[:5]: + print(f"{state.stats_rank}. {state.stateprov}") + print(f" {state.player_count} players") + +# Filter to specific region via post-processing +west_coast = [s for s in stats.stats if s.stateprov in ["WA", "OR", "CA"]] +print(f"\nWest Coast States:") +for state in west_coast: + print(f" {state.stateprov}: {state.player_count} players") +``` + +### Tournament Counts by State/Province + +Get detailed tournament statistics including counts and WPPR points awarded by state: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import StateTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get tournament statistics by state +stats: StateTournamentsResponse = client.stats.state_tournaments(rank_type=StatsRankType.OPEN) + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"\nTop 5 States/Provinces by Tournament Activity:") + +for state in stats.stats[:5]: + print(f"{state.stats_rank}. {state.stateprov}") + print(f" Tournaments: {state.tournament_count}") + print(f" Total Points: {state.total_points_all}") + print(f" Tournament Value: {state.total_points_tournament_value}") +``` + +## Historical Trends + +### Events by Year + +Track yearly growth trends in international pinball competition: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import EventsByYearResponse + +client: IfpaClient = IfpaClient() + +# Get global events by year +stats: EventsByYearResponse = client.stats.events_by_year(rank_type=StatsRankType.OPEN) + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"\nGlobal Tournament Activity (Most Recent 5 Years):") + +for year in stats.stats[:5]: + print(f"\n{year.year}:") + print(f" Tournaments: {year.tournament_count}") + print(f" Countries: {year.country_count}") + print(f" Players: {year.player_count}") + +# Output: +# Type: Events Per Year +# Rank Type: OPEN +# +# Global Tournament Activity (Most Recent 5 Years): +# +# 2024: +# Tournaments: 2847 +# Countries: 45 +# Players: 41203 +``` + +You can filter by country: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import EventsByYearResponse + +client: IfpaClient = IfpaClient() + +# Get US-specific tournament data by year +us_stats: EventsByYearResponse = client.stats.events_by_year( + rank_type=StatsRankType.OPEN, + country_code="US" +) + +for year in us_stats.stats[:5]: + print(f"{year.year}: {year.tournament_count} tournaments, {year.player_count} players") +``` + +### Players by Year + +Track player retention across multiple years to analyze community growth and retention: + +```python +from ifpa_api import IfpaClient +from ifpa_api.models.stats import PlayersByYearResponse + +client: IfpaClient = IfpaClient() + +# Get player retention statistics +stats: PlayersByYearResponse = client.stats.players_by_year() + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"\nPlayer Retention Analysis (Most Recent 5 Years):") + +for year in stats.stats[:5]: + print(f"\n{year.year}:") + print(f" Active players: {year.current_year_count}") + print(f" Also active previous year: {year.previous_year_count}") + print(f" Also active 2 years prior: {year.previous_2_year_count}") + + # Calculate retention rate + retention = (year.previous_year_count / year.current_year_count) * 100 + print(f" Year-over-year retention: {retention:.1f}%") + +# Output: +# Type: Players by Year +# Rank Type: OPEN +# +# Player Retention Analysis (Most Recent 5 Years): +# +# 2024: +# Active players: 41203 +# Also active previous year: 27531 +# Also active 2 years prior: 21456 +# Year-over-year retention: 66.8% +``` + +## Tournament Rankings + +### Largest Tournaments + +Get the top 25 tournaments in IFPA history by player count: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import LargestTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get largest tournaments globally +stats: LargestTournamentsResponse = client.stats.largest_tournaments(rank_type=StatsRankType.OPEN) + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"\nTop 10 Largest Tournaments in IFPA History:") + +for tourney in stats.stats[:10]: + print(f"{tourney.stats_rank}. {tourney.tournament_name} ({tourney.tournament_date})") + print(f" Event: {tourney.event_name}") + print(f" Players: {tourney.player_count}") + print(f" Location: {tourney.country_name}") + +# Output: +# Type: Largest Tournaments +# Rank Type: OPEN +# +# Top 10 Largest Tournaments in IFPA History: +# 1. IFPA16 Main Tournament (2023-08-06) +# Event: INDISC 2023 +# Players: 624 +# Location: United States +``` + +Filter by country: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import LargestTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get largest US tournaments +us_stats: LargestTournamentsResponse = client.stats.largest_tournaments( + rank_type=StatsRankType.OPEN, + country_code="US" +) +``` + +### Most Lucrative Tournaments + +Get the top 25 tournaments by tournament value (WPPR rating), which correlates with competitive prestige: + +```python +from ifpa_api import IfpaClient, StatsRankType, MajorTournament +from ifpa_api.models.stats import LucrativeTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get highest-value major tournaments +stats: LucrativeTournamentsResponse = client.stats.lucrative_tournaments( + major=MajorTournament.YES, + rank_type=StatsRankType.OPEN +) + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"\nTop 10 Highest-Value Major Tournaments:") + +for tourney in stats.stats[:10]: + print(f"{tourney.stats_rank}. {tourney.tournament_name} ({tourney.tournament_date})") + print(f" Event: {tourney.event_name}") + print(f" Value: {tourney.tournament_value}") + print(f" Location: {tourney.country_name}") + +# Output: +# Type: Lucrative Tournaments +# Rank Type: OPEN +# +# Top 10 Highest-Value Major Tournaments: +# 1. IFPA World Championship (2024-04-12) +# Event: IFPA20 Main +# Value: 422.84 +# Location: United States +``` + +Compare major vs non-major tournaments: + +```python +from ifpa_api import IfpaClient, MajorTournament +from ifpa_api.models.stats import LucrativeTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get highest-value major tournaments +major: LucrativeTournamentsResponse = client.stats.lucrative_tournaments(major=MajorTournament.YES) + +# Get highest-value non-major tournaments +non_major: LucrativeTournamentsResponse = client.stats.lucrative_tournaments(major=MajorTournament.NO) + +print("Major Tournaments:") +for t in major.stats[:3]: + print(f" {t.tournament_name}: {t.tournament_value}") + +print("\nNon-Major Tournaments:") +for t in non_major.stats[:3]: + print(f" {t.tournament_name}: {t.tournament_value}") +``` + +## Player Activity Over Time + +### Top Point Earners (Period) + +Get players with the most accumulated WPPR points over a specific time period: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import PointsGivenPeriodResponse + +client: IfpaClient = IfpaClient() + +# Get top point earners for 2024 +stats: PointsGivenPeriodResponse = client.stats.points_given_period( + rank_type=StatsRankType.OPEN, + start_date="2024-01-01", + end_date="2024-12-31", + limit=25 +) + +print(f"Type: {stats.type}") +print(f"Rank Type: {stats.rank_type}") +print(f"Period: {stats.start_date} to {stats.end_date}") +print(f"Results: {stats.return_count}") +print(f"\nTop 10 Point Earners in 2024:") + +for player in stats.stats[:10]: + print(f"{player.stats_rank}. {player.first_name} {player.last_name}") + print(f" WPPR Points: {player.wppr_points}") + print(f" Location: {player.city}, {player.stateprov}, {player.country_name}") + +# Output: +# Type: Points Given - Time Period +# Rank Type: OPEN +# Period: 2024-01-01 to 2024-12-31 +# Results: 25 +# +# Top 10 Point Earners in 2024: +# 1. Raymond Davidson +# WPPR Points: 1234.56 +# Location: Portland, OR, United States +``` + +Filter by country: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import PointsGivenPeriodResponse + +client: IfpaClient = IfpaClient() + +# Get top US point earners for 2024 +us_stats: PointsGivenPeriodResponse = client.stats.points_given_period( + rank_type=StatsRankType.OPEN, + country_code="US", + start_date="2024-01-01", + end_date="2024-12-31", + limit=10 +) + +for player in us_stats.stats: + print(f"{player.first_name} {player.last_name}: {player.wppr_points} pts") +``` + +### Most Active Players (Period) + +Get players who attended the most tournaments during a specific time period: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import EventsAttendedPeriodResponse + +client: IfpaClient = IfpaClient() + +# Get most active players in 2024 +stats: EventsAttendedPeriodResponse = client.stats.events_attended_period( + rank_type=StatsRankType.OPEN, + start_date="2024-01-01", + end_date="2024-12-31", + limit=25 +) + +print(f"Type: {stats.type}") +print(f"Period: {stats.start_date} to {stats.end_date}") +print(f"Results: {stats.return_count}") +print(f"\nTop 10 Most Active Players in 2024:") + +for player in stats.stats[:10]: + name = f"{player.first_name} {player.last_name}" + print(f"{player.stats_rank}. {name}") + print(f" Tournaments: {player.tournament_count}") + print(f" Location: {player.city}, {player.stateprov}") + +# Output: +# Type: Events Attended - Time Period +# Period: 2024-01-01 to 2024-12-31 +# Results: 25 +# +# Top 10 Most Active Players in 2024: +# 1. Erik Thoren +# Tournaments: 156 +# Location: De Pere, WI +``` + +Filter by country: + +```python +from ifpa_api import IfpaClient, StatsRankType +from ifpa_api.models.stats import EventsAttendedPeriodResponse + +client: IfpaClient = IfpaClient() + +# Get most active US players in 2024 +us_stats: EventsAttendedPeriodResponse = client.stats.events_attended_period( + rank_type=StatsRankType.OPEN, + country_code="US", + start_date="2024-01-01", + end_date="2024-12-31", + limit=10 +) +``` + +## Overall IFPA Statistics + +Get comprehensive aggregate statistics about the entire IFPA system: + +```python +from ifpa_api import IfpaClient, SystemCode +from ifpa_api.models.stats import OverallStatsResponse + +client: IfpaClient = IfpaClient() + +# Get overall IFPA statistics +stats: OverallStatsResponse = client.stats.overall(system_code=SystemCode.OPEN) + +print(f"Type: {stats.type}") +print(f"System Code: {stats.system_code}") +print(f"\n=== Player Statistics ===") +print(f"Total players: {stats.stats.overall_player_count:,}") +print(f"Active players (last 2 years): {stats.stats.active_player_count:,}") + +print(f"\n=== Tournament Statistics ===") +print(f"Total tournaments: {stats.stats.tournament_count:,}") +print(f"Tournaments this year: {stats.stats.tournament_count_this_year:,}") +print(f"Tournaments last month: {stats.stats.tournament_count_last_month:,}") +print(f"Total tournament players: {stats.stats.tournament_player_count:,}") +print(f"Avg players/tournament: {stats.stats.tournament_player_count_average:.2f}") + +print(f"\n=== Age Distribution ===") +age = stats.stats.age +print(f"Under 18: {age.age_under_18:.1f}%") +print(f"18-29: {age.age_18_to_29:.1f}%") +print(f"30-39: {age.age_30_to_39:.1f}%") +print(f"40-49: {age.age_40_to_49:.1f}%") +print(f"50+: {age.age_50_to_99:.1f}%") + +# Output: +# Type: Overall Stats +# System Code: OPEN +# +# === Player Statistics === +# Total players: 123,456 +# Active players (last 2 years): 45,678 +# +# === Tournament Statistics === +# Total tournaments: 67,890 +# Tournaments this year: 2,847 +# Tournaments last month: 234 +# Total tournament players: 234,567 +# Avg players/tournament: 18.45 +# +# === Age Distribution === +# Under 18: 2.3% +# 18-29: 18.7% +# 30-39: 28.4% +# 40-49: 31.2% +# 50+: 19.4% +``` + +!!! warning "API Bug: Women's System Code" + As of 2025-11-19, the API has a bug where `system_code="WOMEN"` returns OPEN data. + The API always returns `system_code="OPEN"` regardless of the parameter value. + This is an API limitation, not a client issue. + +## Complete Example: Tournament Trends Report + +Here's a complete example that analyzes tournament trends across multiple dimensions: + +```python +from ifpa_api import IfpaClient +from ifpa_api.core.exceptions import IfpaApiError +from ifpa_api.models.stats import ( + EventsByYearResponse, + LargestTournamentsResponse, + OverallStatsResponse, + StatePlayersResponse, +) + + +def generate_tournament_trends_report() -> None: + """Generate comprehensive tournament trends report.""" + client: IfpaClient = IfpaClient() + + try: + print("=" * 80) + print("IFPA TOURNAMENT TRENDS REPORT") + print("=" * 80) + + # Overall statistics + overall: OverallStatsResponse = client.stats.overall() + print(f"\n=== Overall System Statistics ===") + print(f"Total Players: {overall.stats.overall_player_count:,}") + print(f"Total Tournaments: {overall.stats.tournament_count:,}") + print(f"Tournaments This Year: {overall.stats.tournament_count_this_year:,}") + print(f"Avg Players/Tournament: {overall.stats.tournament_player_count_average:.1f}") + + # Historical growth trend + events: EventsByYearResponse = client.stats.events_by_year() + print(f"\n=== Historical Growth (Last 5 Years) ===") + for year in events.stats[:5]: + print(f"{year.year}:") + print(f" Tournaments: {year.tournament_count:,}") + print(f" Players: {year.player_count:,}") + print(f" Countries: {year.country_count}") + + # Calculate year-over-year growth + if len(events.stats) >= 2: + recent = events.stats[0] + previous = events.stats[1] + tournament_growth = ( + (recent.tournament_count - previous.tournament_count) + / previous.tournament_count * 100 + ) + player_growth = ( + (recent.player_count - previous.player_count) + / previous.player_count * 100 + ) + print(f"\n=== Year-over-Year Growth ===") + print(f"Tournament growth: {tournament_growth:+.1f}%") + print(f"Player growth: {player_growth:+.1f}%") + + # Geographic distribution + states: StatePlayersResponse = client.stats.state_players() + print(f"\n=== Top 10 States/Provinces by Player Count ===") + for state in states.stats[:10]: + print(f"{state.stats_rank:2d}. {state.stateprov}: {state.player_count:,} players") + + # Largest tournaments + largest: LargestTournamentsResponse = client.stats.largest_tournaments() + print(f"\n=== Top 5 Largest Tournaments ===") + for tourney in largest.stats[:5]: + print(f"{tourney.stats_rank}. {tourney.tournament_name} ({tourney.tournament_date})") + print(f" {tourney.player_count} players - {tourney.country_name}") + + print(f"\n" + "=" * 80) + + except IfpaApiError as e: + print(f"API Error: {e}") + if e.status_code: + print(f"Status Code: {e.status_code}") + + +if __name__ == "__main__": + generate_tournament_trends_report() +``` + +## Best Practices + +### Date Formats + +Always use ISO 8601 date format (YYYY-MM-DD) for date parameters: + +```python +from ifpa_api import IfpaClient +from ifpa_api.models.stats import PointsGivenPeriodResponse + +client: IfpaClient = IfpaClient() + +# Correct date format +stats: PointsGivenPeriodResponse = client.stats.points_given_period( + start_date="2024-01-01", # ISO 8601 format + end_date="2024-12-31", + limit=10 +) + +# Incorrect - will cause errors +# stats = client.stats.points_given_period( +# start_date="01/01/2024", # Wrong format +# end_date="12/31/2024", +# ) +``` + +### Error Handling + +Always handle potential API errors: + +```python +from ifpa_api import IfpaClient +from ifpa_api.core.exceptions import IfpaApiError +from ifpa_api.models.stats import CountryPlayersResponse + +client: IfpaClient = IfpaClient() + +try: + stats: CountryPlayersResponse = client.stats.country_players() + print(f"Found {len(stats.stats)} countries") +except IfpaApiError as e: + print(f"API error: {e}") + if e.status_code: + print(f"Status code: {e.status_code}") +``` + +### Type Coercion + +The API returns many count fields as strings. The SDK automatically coerces these to appropriate numeric types (int, float, Decimal): + +```python +from ifpa_api import IfpaClient +from ifpa_api.models.stats import StateTournamentsResponse +from decimal import Decimal + +client: IfpaClient = IfpaClient() + +stats: StateTournamentsResponse = client.stats.state_tournaments() + +# These are already coerced to proper types +for state in stats.stats[:3]: + assert isinstance(state.tournament_count, int) + assert isinstance(state.total_points_all, Decimal) + assert isinstance(state.total_points_tournament_value, Decimal) +``` + +### Caching Results + +For frequently accessed statistical data, consider caching to reduce API calls: + +```python +from functools import lru_cache +from ifpa_api import IfpaClient +from ifpa_api.models.stats import CountryPlayersResponse + + +@lru_cache(maxsize=10) +def get_cached_country_stats(rank_type: str = "OPEN") -> CountryPlayersResponse: + """Get country statistics with caching.""" + client: IfpaClient = IfpaClient() + return client.stats.country_players(rank_type=rank_type) + + +# First call fetches from API +stats: CountryPlayersResponse = get_cached_country_stats("OPEN") + +# Subsequent calls use cache +stats: CountryPlayersResponse = get_cached_country_stats("OPEN") # Instant +``` + +## Known Limitations + +### API String Coercion + +Many stats endpoints return numeric fields as strings. The SDK automatically handles this conversion, but you should be aware: + +- **Count fields** (player_count, tournament_count): Converted to `int` +- **Point fields** (wppr_points, total_points): Converted to `Decimal` +- **Value fields** (tournament_value): Converted to `float` + +### Women's System Code Bug + +The `overall()` endpoint has a known API bug where `system_code="WOMEN"` returns OPEN data. The API always returns `system_code="OPEN"` regardless of the parameter value. + +### Empty Period Results + +When querying period-based endpoints (`points_given_period`, `events_attended_period`), empty results are valid if no tournaments occurred in the specified date range: + +```python +from ifpa_api import IfpaClient +from ifpa_api.models.stats import PointsGivenPeriodResponse + +client: IfpaClient = IfpaClient() + +# May return empty stats array if no tournaments in range +stats: PointsGivenPeriodResponse = client.stats.points_given_period( + start_date="2010-01-01", + end_date="2010-01-02" +) + +if stats.return_count == 0: + print("No results for this period") +else: + print(f"Found {stats.return_count} results") +``` + +### Response Sizes + +Stats endpoints return different sized responses. Understanding typical sizes helps with performance planning. + +**Note**: Counts below verified against live IFPA API on 2025-11-22 and will grow as IFPA expands internationally. + +**Fixed Size Responses:** +- `overall()`: Single object with system-wide statistics (not an array) +- `country_players(rank_type="OPEN")`: 51 countries with active players +- `country_players(rank_type="WOMEN")`: 27 countries with active women players +- `state_players()`: 72 states/provinces (North America only) + +**Variable Size Responses:** +- `state_tournaments(rank_type="OPEN")`: 54 states with tournament data +- `events_by_year()`: One entry per active year (currently 10 years, grows annually) +- `players_by_year()`: One entry per active year (currently 10 years, grows annually) +- `largest_tournaments()`: Exactly 25 tournaments (fixed API limit) +- `lucrative_tournaments()`: Exactly 25 tournaments (fixed API limit) + +**Period-Based Responses (varies by date range and limit parameter):** +- `points_given_period()`: 0 to N results (defaults to max 25 without explicit limit) +- `events_attended_period()`: 0 to N results (defaults to max 25 without explicit limit) + +**Performance Tips**: +- Period endpoints default to 25 results - use `limit` parameter for larger result sets +- Wide date ranges (e.g., full year) may hit the default 25-result cap +- Use smaller date ranges or explicit `limit=100` for comprehensive period queries + +## API Coverage + +The Stats resource provides access to all 10 statistical endpoints in the IFPA API v2.1: + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/stats/country_players` | `country_players()` | Player counts by country | +| `/stats/state_players` | `state_players()` | Player counts by state/province (North America) | +| `/stats/state_tournaments` | `state_tournaments()` | Tournament counts and points by state | +| `/stats/events_by_year` | `events_by_year()` | Historical tournament trends by year | +| `/stats/players_by_year` | `players_by_year()` | Player retention metrics by year | +| `/stats/largest_tournaments` | `largest_tournaments()` | Top 25 tournaments by player count | +| `/stats/lucrative_tournaments` | `lucrative_tournaments()` | Top 25 tournaments by WPPR value | +| `/stats/points_given_period` | `points_given_period()` | Top point earners in date range | +| `/stats/events_attended_period` | `events_attended_period()` | Most active players in date range | +| `/stats/overall` | `overall()` | Overall IFPA system statistics | + +All endpoints were verified operational as of 2025-11-19. + +## Related Resources + +- [Rankings](rankings.md) - View current player rankings +- [Tournaments](tournaments.md) - View tournament details and results +- [Players](players.md) - View player profiles and history +- [Error Handling](../guides/error-handling.md) - Handle API errors +- [Exceptions Reference](../api-client-reference/exceptions.md) - Exception types diff --git a/docs/resources/tournaments.md b/docs/resources/tournaments.md index 8aa7f4a..a005526 100644 --- a/docs/resources/tournaments.md +++ b/docs/resources/tournaments.md @@ -57,8 +57,17 @@ results: TournamentSearchResponse = (client.tournament.query() .state("OR") .get()) -# Filter by tournament type +# Filter by tournament type (using enum - preferred) +from ifpa_api import TournamentSearchType + results: TournamentSearchResponse = (client.tournament.query() + .country("US") + .tournament_type(TournamentSearchType.WOMEN) + .limit(25) + .get()) + +# Using strings (backwards compatible) +results_str: TournamentSearchResponse = (client.tournament.query() .country("US") .tournament_type("women") .limit(25) @@ -133,9 +142,11 @@ id_tournaments: TournamentSearchResponse = us_query.state("ID").limit(25).get() ca_tournaments: TournamentSearchResponse = us_query.state("CA").limit(25).get() # Create a reusable date range query +from ifpa_api import TournamentSearchType + year_2024 = client.tournament.query().date_range("2024-01-01", "2024-12-31") us_2024 = year_2024.country("US").get() -women_2024 = year_2024.tournament_type("women").get() +women_2024 = year_2024.tournament_type(TournamentSearchType.WOMEN).get() ``` ## Available Filters @@ -149,7 +160,7 @@ The fluent query builder provides these methods: | `.state(stateprov)` | `str` | Filter by state/province code | | `.country(country)` | `str` | Filter by country code (e.g., "US", "CA") | | `.date_range(start, end)` | `str, str` | Date range filter (both required, YYYY-MM-DD format) | -| `.tournament_type(type)` | `str` | Tournament type (e.g., "open", "women", "youth") | +| `.tournament_type(type)` | `TournamentSearchType \| str` | Tournament type (`TournamentSearchType.OPEN`, `WOMEN`, `YOUTH`, `LEAGUE` or strings) | | `.offset(start_position)` | `int` | Pagination offset (0-based) | | `.limit(count)` | `int` | Maximum number of results | | `.get()` | - | Execute query and return results | @@ -168,7 +179,7 @@ The fluent query builder provides these methods: Combine multiple filters for precise searches: ```python -from ifpa_api import IfpaClient +from ifpa_api import IfpaClient, TournamentSearchType from ifpa_api.models.tournaments import TournamentSearchResponse client: IfpaClient = IfpaClient() @@ -177,7 +188,7 @@ client: IfpaClient = IfpaClient() results: TournamentSearchResponse = (client.tournament.query() .country("US") .date_range("2024-01-01", "2024-12-31") - .tournament_type("women") + .tournament_type(TournamentSearchType.WOMEN) .limit(100) .get()) diff --git a/mkdocs.yml b/mkdocs.yml index c6e04b4..16fb965 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ nav: - Rankings: resources/rankings.md - Tournaments: resources/tournaments.md - Series: resources/series.md + - Stats: resources/stats.md - Reference: resources/reference.md - API Client Reference: - Client: api-client-reference/client.md diff --git a/poetry.lock b/poetry.lock index 709a382..b47c848 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1516,4 +1516,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "778e313f4a775e084ac4b5cda9a841b1017bdb5f504f9508c83cbb9ba82e79c4" +content-hash = "55cd5ff401dae2a762284789a5ad945b711eccf6359dc92a9be21f7dbf1f69fe" diff --git a/pyproject.toml b/pyproject.toml index 42dc170..fe302a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [tool.poetry] name = "ifpa-api" -version = "0.3.0" +version = "0.4.0" description = "Typed Python client for the IFPA (International Flipper Pinball Association) API" authors = ["John Sosoka "] readme = "README.md" license = "MIT" homepage = "https://github.com/johnsosoka/ifpa-api-python" repository = "https://github.com/johnsosoka/ifpa-api-python" +documentation = "https://ifpa-api.readthedocs.io/" keywords = ["ifpa", "pinball", "api", "client", "sdk"] classifiers = [ "Development Status :: 3 - Alpha", @@ -25,6 +26,13 @@ python = "^3.11" requests = "^2.31.0" pydantic = "^2.0.0" +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +mkdocs = "^1.5.0" +mkdocs-material = "^9.5.0" + [tool.poetry.group.dev.dependencies] pytest = "^8.0.0" pytest-cov = "^4.1.0" @@ -34,8 +42,6 @@ ruff = "^0.14.5" black = "^24.0.0" mypy = "^1.8.0" pre-commit = "^3.6.0" -mkdocs = "^1.5.0" -mkdocs-material = "^9.5.0" datamodel-code-generator = ">=0.25,<0.36" types-requests = "^2.31.0" diff --git a/src/ifpa_api/__init__.py b/src/ifpa_api/__init__.py index ef5feda..bcb30f6 100644 --- a/src/ifpa_api/__init__.py +++ b/src/ifpa_api/__init__.py @@ -43,9 +43,19 @@ SeriesPlayerNotFoundError, TournamentNotLeagueError, ) -from ifpa_api.models.common import RankingSystem, ResultType, TimePeriod, TournamentType +from ifpa_api.models.common import ( + MajorTournament, + RankingDivision, + RankingSystem, + ResultType, + StatsRankType, + SystemCode, + TimePeriod, + TournamentSearchType, + TournamentType, +) -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = [ # Main client @@ -55,6 +65,11 @@ "RankingSystem", "ResultType", "TournamentType", + "StatsRankType", + "SystemCode", + "MajorTournament", + "RankingDivision", + "TournamentSearchType", # Exceptions "IfpaError", "IfpaApiError", diff --git a/src/ifpa_api/client.py b/src/ifpa_api/client.py index ca794f6..a1c653b 100644 --- a/src/ifpa_api/client.py +++ b/src/ifpa_api/client.py @@ -13,6 +13,7 @@ from ifpa_api.resources.rankings import RankingsClient from ifpa_api.resources.reference import ReferenceClient from ifpa_api.resources.series import SeriesClient +from ifpa_api.resources.stats import StatsClient from ifpa_api.resources.tournament import TournamentClient @@ -102,6 +103,7 @@ def __init__( self._reference_client: ReferenceClient | None = None self._tournament_client: TournamentClient | None = None self._series_client: SeriesClient | None = None + self._stats_client: StatsClient | None = None @property def director(self) -> DirectorClient: @@ -259,6 +261,36 @@ def series(self) -> SeriesClient: self._series_client = SeriesClient(self._http, self._config.validate_requests) return self._series_client + @property + def stats(self) -> StatsClient: + """Access the stats resource client. + + Returns: + StatsClient instance for accessing statistical data and metrics + + Example: + ```python + # Get player counts by country + country_stats = client.stats.country_players(rank_type="OPEN") + + # Get state/province statistics + state_stats = client.stats.state_players() + + # Get overall IFPA statistics + overall = client.stats.overall() + print(f"Total players: {overall.stats.overall_player_count}") + + # Get points given in a period + points = client.stats.points_given_period( + start_date="2024-01-01", + end_date="2024-12-31" + ) + ``` + """ + if self._stats_client is None: + self._stats_client = StatsClient(self._http, self._config.validate_requests) + return self._stats_client + def close(self) -> None: """Close the HTTP client session. diff --git a/src/ifpa_api/models/__init__.py b/src/ifpa_api/models/__init__.py index 51b10ff..c7ac6e2 100644 --- a/src/ifpa_api/models/__init__.py +++ b/src/ifpa_api/models/__init__.py @@ -7,8 +7,11 @@ from ifpa_api.models.calendar import CalendarEvent, CalendarResponse from ifpa_api.models.common import ( IfpaBaseModel, + MajorTournament, RankingSystem, ResultType, + StatsRankType, + SystemCode, TimePeriod, TournamentType, ) @@ -76,6 +79,29 @@ SeriesTournament, SeriesTournamentsResponse, ) +from ifpa_api.models.stats import ( + AgeGenderStats, + CountryPlayersResponse, + CountryPlayerStat, + EventsAttendedPeriodResponse, + EventsAttendedPeriodStat, + EventsByYearResponse, + EventsByYearStat, + LargestTournamentsResponse, + LargestTournamentStat, + LucrativeTournamentsResponse, + LucrativeTournamentStat, + OverallStats, + OverallStatsResponse, + PlayersByYearResponse, + PlayersByYearStat, + PointsGivenPeriodResponse, + PointsGivenPeriodStat, + StatePlayersResponse, + StatePlayerStat, + StateTournamentsResponse, + StateTournamentStat, +) from ifpa_api.models.tournaments import ( FormatDefinition, LeagueSession, @@ -104,6 +130,9 @@ "RankingSystem", "ResultType", "TournamentType", + "StatsRankType", + "SystemCode", + "MajorTournament", # Director "Director", "DirectorStats", @@ -183,4 +212,26 @@ # Calendar "CalendarEvent", "CalendarResponse", + # Stats + "CountryPlayerStat", + "CountryPlayersResponse", + "StatePlayerStat", + "StatePlayersResponse", + "StateTournamentStat", + "StateTournamentsResponse", + "EventsByYearStat", + "EventsByYearResponse", + "PlayersByYearStat", + "PlayersByYearResponse", + "LargestTournamentStat", + "LargestTournamentsResponse", + "LucrativeTournamentStat", + "LucrativeTournamentsResponse", + "PointsGivenPeriodStat", + "PointsGivenPeriodResponse", + "EventsAttendedPeriodStat", + "EventsAttendedPeriodResponse", + "AgeGenderStats", + "OverallStats", + "OverallStatsResponse", ] diff --git a/src/ifpa_api/models/common.py b/src/ifpa_api/models/common.py index 866f3b3..b81b6d1 100644 --- a/src/ifpa_api/models/common.py +++ b/src/ifpa_api/models/common.py @@ -65,6 +65,97 @@ class TournamentType(str, Enum): WOMEN = "women" +class StatsRankType(str, Enum): + """Ranking type filter for statistical queries. + + Attributes: + OPEN: All players/tournaments + WOMEN: Women's division only + """ + + OPEN = "OPEN" + WOMEN = "WOMEN" + + +class SystemCode(str, Enum): + """System code for overall statistics queries. + + Note: As of 2025-11, API bug causes WOMEN to return OPEN data. + + Attributes: + OPEN: Open division statistics + WOMEN: Women's division statistics (currently returns OPEN data due to API bug) + """ + + OPEN = "OPEN" + WOMEN = "WOMEN" + + +class MajorTournament(str, Enum): + """Major tournament filter for tournament value queries. + + Attributes: + YES: Major tournaments only + NO: Non-major tournaments only + """ + + YES = "Y" + NO = "N" + + +class RankingDivision(str, Enum): + """Division filter for rankings queries. + + Used in rankings endpoints to filter by player division. + + Attributes: + OPEN: Open division (all players) + WOMEN: Women's division only + + Example: + ```python + from ifpa_api import RankingDivision + + # Get women's rankings for open tournaments + rankings = client.rankings.women( + tournament_type=RankingDivision.OPEN, + count=50 + ) + ``` + """ + + OPEN = "OPEN" + WOMEN = "WOMEN" + + +class TournamentSearchType(str, Enum): + """Tournament type filter for search queries. + + Used in tournament search to filter by tournament type/format. + + Attributes: + OPEN: Open tournaments (all players) + WOMEN: Women-only tournaments + YOUTH: Youth tournaments + LEAGUE: League-format tournaments + + Example: + ```python + from ifpa_api import TournamentSearchType + + # Search for women's tournaments + results = (client.tournament.search("Championship") + .tournament_type(TournamentSearchType.WOMEN) + .get()) + ``` + """ + + OPEN = "open" + WOMEN = "women" + YOUTH = "youth" + LEAGUE = "league" + + class IfpaBaseModel(BaseModel): """Base model for all IFPA SDK Pydantic models. diff --git a/src/ifpa_api/models/stats.py b/src/ifpa_api/models/stats.py new file mode 100644 index 0000000..fb8246d --- /dev/null +++ b/src/ifpa_api/models/stats.py @@ -0,0 +1,602 @@ +"""Stats-related Pydantic models. + +Models for IFPA statistical endpoints including country/state statistics, +tournament data, player activity metrics, and overall IFPA statistics. +""" + +from decimal import Decimal +from typing import Any + +from pydantic import Field, field_validator + +from ifpa_api.models.common import IfpaBaseModel + + +class CountryPlayerStat(IfpaBaseModel): + """Player count statistics for a single country. + + Attributes: + country_name: Full name of the country + country_code: ISO country code + player_count: Number of registered players in this country + stats_rank: Rank position by player count (1 = most players) + """ + + country_name: str + country_code: str + player_count: int + stats_rank: int + + @field_validator("player_count", mode="before") + @classmethod + def coerce_player_count(cls, v: Any) -> int: + """Convert string player count to integer. + + The IFPA API returns player_count as a string (e.g., "47101"). + This validator coerces it to an integer for type safety. + + Args: + v: The player_count value from the API (may be str or int) + + Returns: + The player count as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + +class CountryPlayersResponse(IfpaBaseModel): + """Response from GET /stats/country_players endpoint. + + Attributes: + type: Response type description + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of country player statistics, sorted by player count + """ + + type: str + rank_type: str + stats: list[CountryPlayerStat] = Field(default_factory=list) + + +class StatePlayerStat(IfpaBaseModel): + """Player count statistics for a single state/province. + + Note: This endpoint is specific to North American states/provinces. + The "Unknown" state contains players without location data. + + Attributes: + stateprov: State or province code (e.g., "WA", "CA", "Unknown") + player_count: Number of registered players in this state/province + stats_rank: Rank position by player count (1 = most players) + """ + + stateprov: str + player_count: int + stats_rank: int + + @field_validator("player_count", mode="before") + @classmethod + def coerce_player_count(cls, v: Any) -> int: + """Convert string player count to integer. + + Args: + v: The player_count value from the API (may be str or int) + + Returns: + The player count as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + +class StatePlayersResponse(IfpaBaseModel): + """Response from GET /stats/state_players endpoint. + + Attributes: + type: Response type description + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of state/province player statistics, sorted by player count + """ + + type: str + rank_type: str + stats: list[StatePlayerStat] = Field(default_factory=list) + + +class StateTournamentStat(IfpaBaseModel): + """Tournament and points statistics for a single state/province. + + Provides detailed financial/points analysis by state including total + WPPR points awarded and tournament values. + + Attributes: + stateprov: State or province code (e.g., "WA", "CA") + tournament_count: Number of tournaments held in this state + total_points_all: Total WPPR points awarded across all tournaments + total_points_tournament_value: Cumulative tournament rating values + stats_rank: Rank position by tournament count (1 = most tournaments) + """ + + stateprov: str + tournament_count: int + total_points_all: Decimal + total_points_tournament_value: Decimal + stats_rank: int + + @field_validator("tournament_count", mode="before") + @classmethod + def coerce_tournament_count(cls, v: Any) -> int: + """Convert string tournament count to integer. + + Args: + v: The tournament_count value from the API (may be str or int) + + Returns: + The tournament count as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + @field_validator("total_points_all", "total_points_tournament_value", mode="before") + @classmethod + def coerce_decimal_fields(cls, v: Any) -> Decimal: + """Convert string point values to Decimal for precision. + + The API returns point values as strings (e.g., "232841.4800"). + Using Decimal preserves full precision for financial calculations. + + Args: + v: The point value from the API (may be str, int, float, or Decimal) + + Returns: + The point value as a Decimal + """ + if isinstance(v, str): + return Decimal(v) + return Decimal(str(v)) + + +class StateTournamentsResponse(IfpaBaseModel): + """Response from GET /stats/state_tournaments endpoint. + + Attributes: + type: Response type description + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of state tournament statistics, sorted by tournament count + """ + + type: str + rank_type: str + stats: list[StateTournamentStat] = Field(default_factory=list) + + +class EventsByYearStat(IfpaBaseModel): + """Tournament and player statistics for a single year. + + Shows yearly growth trends in international pinball competition. + + Attributes: + year: Year of the statistics (e.g., "2024") + country_count: Number of countries with tournaments + tournament_count: Total number of tournaments held + player_count: Total number of unique players who competed + stats_rank: Rank position (1 = most recent year) + """ + + year: str + country_count: int + tournament_count: int + player_count: int + stats_rank: int + + @field_validator("country_count", "tournament_count", "player_count", mode="before") + @classmethod + def coerce_count_fields(cls, v: Any) -> int: + """Convert string count fields to integers. + + Args: + v: The count value from the API (may be str or int) + + Returns: + The count as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + +class EventsByYearResponse(IfpaBaseModel): + """Response from GET /stats/events_by_year endpoint. + + Attributes: + type: Response type description + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of yearly statistics, sorted by year descending + """ + + type: str + rank_type: str + stats: list[EventsByYearStat] = Field(default_factory=list) + + +class PlayersByYearStat(IfpaBaseModel): + """Player activity and retention statistics for a single year. + + This unique endpoint tracks player retention across multiple years, + showing how many players were active in consecutive years. + + Attributes: + year: Year of the statistics (e.g., "2024") + current_year_count: Players active in this year + previous_year_count: Players also active in the previous year + previous_2_year_count: Players also active 2 years prior + stats_rank: Rank position (1 = most recent year) + """ + + year: str + current_year_count: int + previous_year_count: int + previous_2_year_count: int + stats_rank: int + + @field_validator( + "current_year_count", "previous_year_count", "previous_2_year_count", mode="before" + ) + @classmethod + def coerce_count_fields(cls, v: Any) -> int: + """Convert string count fields to integers. + + Args: + v: The count value from the API (may be str or int) + + Returns: + The count as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + +class PlayersByYearResponse(IfpaBaseModel): + """Response from GET /stats/players_by_year endpoint. + + Attributes: + type: Response type description + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of yearly player retention statistics + """ + + type: str + rank_type: str + stats: list[PlayersByYearStat] = Field(default_factory=list) + + +class LargestTournamentStat(IfpaBaseModel): + """Information about a single large tournament by player count. + + Attributes: + country_name: Full name of the country where tournament was held + country_code: ISO country code + player_count: Number of participants + tournament_id: Unique tournament identifier + tournament_name: Tournament name + event_name: Specific event name within the tournament + tournament_date: Date of the tournament (YYYY-MM-DD format) + stats_rank: Rank position by player count (1 = largest tournament) + """ + + country_name: str + country_code: str + player_count: int + tournament_id: int + tournament_name: str + event_name: str + tournament_date: str + stats_rank: int + + @field_validator("player_count", mode="before") + @classmethod + def coerce_player_count(cls, v: Any) -> int: + """Convert string player count to integer. + + Args: + v: The player_count value from the API (may be str or int) + + Returns: + The player count as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + +class LargestTournamentsResponse(IfpaBaseModel): + """Response from GET /stats/largest_tournaments endpoint. + + Returns the top 25 tournaments by player count. + + Attributes: + type: Response type description + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of largest tournaments, sorted by player count + """ + + type: str + rank_type: str + stats: list[LargestTournamentStat] = Field(default_factory=list) + + +class LucrativeTournamentStat(IfpaBaseModel): + """Information about a single high-value tournament. + + Attributes: + country_name: Full name of the country where tournament was held + country_code: ISO country code + tournament_id: Unique tournament identifier + tournament_name: Tournament name + event_name: Specific event name within the tournament + tournament_date: Date of the tournament (YYYY-MM-DD format) + tournament_value: WPPR value/rating of the tournament + stats_rank: Rank position by tournament value (1 = highest value) + """ + + country_name: str + country_code: str + tournament_id: int + tournament_name: str + event_name: str + tournament_date: str + tournament_value: float + stats_rank: int + + +class LucrativeTournamentsResponse(IfpaBaseModel): + """Response from GET /stats/lucrative_tournaments endpoint. + + Returns top 25 tournaments by WPPR value. Can be filtered by major + tournament status using the 'major' parameter. + + Attributes: + type: Response type description + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of high-value tournaments, sorted by tournament value + """ + + type: str + rank_type: str + stats: list[LucrativeTournamentStat] = Field(default_factory=list) + + +class PointsGivenPeriodStat(IfpaBaseModel): + """Points earned by a player during a specific time period. + + Used to identify top point earners in a date range. + + Attributes: + player_id: Unique player identifier + first_name: Player's first name + last_name: Player's last name + country_name: Full country name + country_code: ISO country code + wppr_points: Total WPPR points earned during the period + stats_rank: Rank position by points earned (1 = most points) + """ + + player_id: int + first_name: str + last_name: str + country_name: str + country_code: str + wppr_points: Decimal + stats_rank: int + + @field_validator("player_id", mode="before") + @classmethod + def coerce_player_id(cls, v: Any) -> int: + """Convert string player_id to integer. + + The API returns player_id as a string in period endpoints. + + Args: + v: The player_id value from the API (may be str or int) + + Returns: + The player ID as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + @field_validator("wppr_points", mode="before") + @classmethod + def coerce_wppr_points(cls, v: Any) -> Decimal: + """Convert string WPPR points to Decimal for precision. + + The API returns wppr_points as a string (e.g., "4264.61"). + Using Decimal preserves full precision for point calculations. + + Args: + v: The wppr_points value from the API (may be str, int, float, or Decimal) + + Returns: + The WPPR points as a Decimal + """ + if isinstance(v, str): + return Decimal(v) + return Decimal(str(v)) + + +class PointsGivenPeriodResponse(IfpaBaseModel): + """Response from GET /stats/points_given_period endpoint. + + Returns top point earners for a date range with support for limit, + rank_type, and country_code filtering. + + Attributes: + type: Response type description + start_date: Start date of the period (YYYY-MM-DD format) + end_date: End date of the period (YYYY-MM-DD format) + return_count: Number of results returned + rank_type: Ranking system used (OPEN or WOMEN) + stats: List of player point statistics for the period + """ + + type: str + start_date: str + end_date: str + return_count: int + rank_type: str + stats: list[PointsGivenPeriodStat] = Field(default_factory=list) + + +class EventsAttendedPeriodStat(IfpaBaseModel): + """Tournament attendance by a player during a specific time period. + + Used to identify most active players by tournament count. + + Attributes: + player_id: Unique player identifier + first_name: Player's first name + last_name: Player's last name + country_name: Full country name + country_code: ISO country code + tournament_count: Number of tournaments attended during the period + stats_rank: Rank position by tournament count (1 = most tournaments) + """ + + player_id: int + first_name: str + last_name: str + country_name: str + country_code: str + tournament_count: int + stats_rank: int + + @field_validator("player_id", mode="before") + @classmethod + def coerce_player_id(cls, v: Any) -> int: + """Convert string player_id to integer. + + The API returns player_id as a string in period endpoints. + + Args: + v: The player_id value from the API (may be str or int) + + Returns: + The player ID as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + @field_validator("tournament_count", mode="before") + @classmethod + def coerce_tournament_count(cls, v: Any) -> int: + """Convert string tournament count to integer. + + Args: + v: The tournament_count value from the API (may be str or int) + + Returns: + The tournament count as an integer + """ + if isinstance(v, str): + return int(v) + return int(v) + + +class EventsAttendedPeriodResponse(IfpaBaseModel): + """Response from GET /stats/events_attended_period endpoint. + + Returns most active players by tournament attendance for a date range + with support for limit, rank_type, and country_code filtering. + + Note: Unlike points_given_period, this endpoint does not include a + rank_type field in the response. + + Attributes: + type: Response type description + start_date: Start date of the period (YYYY-MM-DD format) + end_date: End date of the period (YYYY-MM-DD format) + return_count: Number of results returned + stats: List of player tournament attendance statistics + """ + + type: str + start_date: str + end_date: str + return_count: int + stats: list[EventsAttendedPeriodStat] = Field(default_factory=list) + + +class AgeGenderStats(IfpaBaseModel): + """Age distribution statistics for IFPA players. + + All values are percentages representing the proportion of players + in each age bracket. + + Attributes: + age_under_18: Percentage of players under 18 years old + age_18_to_29: Percentage of players aged 18-29 + age_30_to_39: Percentage of players aged 30-39 + age_40_to_49: Percentage of players aged 40-49 + age_50_to_99: Percentage of players aged 50 and above + """ + + age_under_18: float + age_18_to_29: float + age_30_to_39: float + age_40_to_49: float + age_50_to_99: float + + +class OverallStats(IfpaBaseModel): + """Overall IFPA statistics and aggregate metrics. + + Note: Unlike other stats endpoints, all numeric fields here are + returned as proper numbers (int/float) rather than strings. + + Attributes: + overall_player_count: Total registered players across all time + active_player_count: Currently active players + tournament_count: Total tournaments held + tournament_count_last_month: Tournaments in the past month + tournament_count_this_year: Tournaments held in the current year + tournament_player_count: Total tournament participations (player-events) + tournament_player_count_average: Average number of players per tournament + age: Age distribution statistics for players + """ + + overall_player_count: int + active_player_count: int + tournament_count: int + tournament_count_last_month: int + tournament_count_this_year: int + tournament_player_count: int + tournament_player_count_average: float + age: AgeGenderStats + + +class OverallStatsResponse(IfpaBaseModel): + """Response from GET /stats/overall endpoint. + + Note: This endpoint has a unique structure where 'stats' is a single + object rather than an array like other stats endpoints. + + API Bug: As of 2025-11-19, the system_code=WOMEN parameter appears to + be ignored, and the endpoint always returns OPEN statistics regardless + of the requested system_code. + + Attributes: + type: Response type description + system_code: Ranking system used (OPEN or WOMEN) + stats: Overall statistics object (not an array) + """ + + type: str + system_code: str + stats: OverallStats diff --git a/src/ifpa_api/resources/__init__.py b/src/ifpa_api/resources/__init__.py index 8293826..0121896 100644 --- a/src/ifpa_api/resources/__init__.py +++ b/src/ifpa_api/resources/__init__.py @@ -9,6 +9,7 @@ from ifpa_api.resources.rankings import RankingsClient from ifpa_api.resources.reference import ReferenceClient from ifpa_api.resources.series import SeriesClient +from ifpa_api.resources.stats import StatsClient from ifpa_api.resources.tournament import TournamentClient __all__ = [ @@ -18,4 +19,5 @@ "ReferenceClient", "TournamentClient", "SeriesClient", + "StatsClient", ] diff --git a/src/ifpa_api/resources/rankings/client.py b/src/ifpa_api/resources/rankings/client.py index 5073771..e9c5b41 100644 --- a/src/ifpa_api/resources/rankings/client.py +++ b/src/ifpa_api/resources/rankings/client.py @@ -5,6 +5,7 @@ """ from ifpa_api.core.base import BaseResourceClient +from ifpa_api.models.common import RankingDivision from ifpa_api.models.rankings import ( CountryRankingsResponse, CustomRankingListResponse, @@ -76,7 +77,7 @@ def wppr( def women( self, - tournament_type: str = "OPEN", + tournament_type: RankingDivision | str = "OPEN", start_pos: int | str | None = None, count: int | str | None = None, country: str | None = None, @@ -85,7 +86,7 @@ def women( Args: tournament_type: Tournament type filter - "OPEN" for all tournaments or - "WOMEN" for women-only tournaments + "WOMEN" for women-only tournaments. Accepts RankingDivision enum or string. start_pos: Starting position for pagination count: Number of results to return (max 250) country: Filter by country code @@ -98,13 +99,32 @@ def women( Example: ```python - # Get women's rankings from all tournaments - rankings = client.rankings.women(tournament_type="OPEN", start_pos=0, count=50) + from ifpa_api import RankingDivision - # Get women's rankings from women-only tournaments - women_only = client.rankings.women(tournament_type="WOMEN", count=50) + # Get women's rankings from all tournaments (using enum) + rankings = client.rankings.women( + tournament_type=RankingDivision.OPEN, + start_pos=0, + count=50 + ) + + # Get women's rankings from women-only tournaments (using enum) + women_only = client.rankings.women( + tournament_type=RankingDivision.WOMEN, + count=50 + ) + + # Using string (backwards compatible) + rankings = client.rankings.women(tournament_type="OPEN", count=50) ``` """ + # Extract enum value if enum passed, otherwise use string directly + type_value = ( + tournament_type.value + if isinstance(tournament_type, RankingDivision) + else tournament_type + ) + params = {} if start_pos is not None: params["start_pos"] = start_pos @@ -114,7 +134,7 @@ def women( params["country"] = country response = self._http._request( - "GET", f"/rankings/women/{tournament_type.lower()}", params=params + "GET", f"/rankings/women/{type_value.lower()}", params=params ) return RankingsResponse.model_validate(response) @@ -190,7 +210,7 @@ def virtual( def pro( self, - ranking_system: str = "OPEN", + ranking_system: RankingDivision | str = "OPEN", start_pos: int | None = None, count: int | None = None, ) -> RankingsResponse: @@ -198,7 +218,7 @@ def pro( Args: ranking_system: Ranking system filter - "OPEN" for open division or - "WOMEN" for women's division + "WOMEN" for women's division. Accepts RankingDivision enum or string. start_pos: Starting position for pagination count: Number of results to return (max 250) @@ -210,13 +230,30 @@ def pro( Example: ```python - # Get open division pro rankings - rankings = client.rankings.pro(ranking_system="OPEN", start_pos=0, count=50) + from ifpa_api import RankingDivision - # Get women's division pro rankings - women_pro = client.rankings.pro(ranking_system="WOMEN", count=50) + # Get open division pro rankings (using enum) + rankings = client.rankings.pro( + ranking_system=RankingDivision.OPEN, + start_pos=0, + count=50 + ) + + # Get women's division pro rankings (using enum) + women_pro = client.rankings.pro( + ranking_system=RankingDivision.WOMEN, + count=50 + ) + + # Using string (backwards compatible) + rankings = client.rankings.pro(ranking_system="OPEN", count=50) ``` """ + # Extract enum value if enum passed, otherwise use string directly + system_value = ( + ranking_system.value if isinstance(ranking_system, RankingDivision) else ranking_system + ) + params = {} if start_pos is not None: params["start_pos"] = start_pos @@ -224,7 +261,7 @@ def pro( params["count"] = count response = self._http._request( - "GET", f"/rankings/pro/{ranking_system.lower()}", params=params + "GET", f"/rankings/pro/{system_value.lower()}", params=params ) return RankingsResponse.model_validate(response) diff --git a/src/ifpa_api/resources/stats/__init__.py b/src/ifpa_api/resources/stats/__init__.py new file mode 100644 index 0000000..8abc0ad --- /dev/null +++ b/src/ifpa_api/resources/stats/__init__.py @@ -0,0 +1,8 @@ +"""Stats resource module. + +Provides the StatsClient for accessing IFPA statistical data. +""" + +from ifpa_api.resources.stats.client import StatsClient + +__all__ = ["StatsClient"] diff --git a/src/ifpa_api/resources/stats/client.py b/src/ifpa_api/resources/stats/client.py new file mode 100644 index 0000000..9de2f06 --- /dev/null +++ b/src/ifpa_api/resources/stats/client.py @@ -0,0 +1,545 @@ +"""Stats resource client. + +Provides access to IFPA statistical data including country/state player counts, +tournament metrics, player activity over time periods, and overall IFPA statistics. +""" + +from typing import Any + +from ifpa_api.core.base import BaseResourceClient +from ifpa_api.models.common import MajorTournament, StatsRankType, SystemCode +from ifpa_api.models.stats import ( + CountryPlayersResponse, + EventsAttendedPeriodResponse, + EventsByYearResponse, + LargestTournamentsResponse, + LucrativeTournamentsResponse, + OverallStatsResponse, + PlayersByYearResponse, + PointsGivenPeriodResponse, + StatePlayersResponse, + StateTournamentsResponse, +) + +# ============================================================================ +# Stats Resource Client - IFPA Statistical Data Access +# ============================================================================ + + +class StatsClient(BaseResourceClient): + """Client for IFPA statistical data queries. + + This client provides access to various statistical endpoints maintained by IFPA, + including player counts by region, tournament metrics, historical trends, + and overall system statistics. + + All endpoints were verified operational as of 2025-11-19. Note that many count + fields are returned as strings by the API and are automatically coerced to + integers/decimals by the Pydantic models. + + Attributes: + _http: The HTTP client instance + _validate_requests: Whether to validate request parameters + """ + + def country_players(self, rank_type: StatsRankType | str = "OPEN") -> CountryPlayersResponse: + """Get player count statistics by country. + + Returns comprehensive list of all countries with registered players, + sorted by player count descending. + + Args: + rank_type: Ranking type - "OPEN" for all players or "WOMEN" for + women's rankings. Accepts StatsRankType enum or string. Defaults to "OPEN". + + Returns: + CountryPlayersResponse with player counts for each country. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType + + # Get all countries with player counts (using enum) + stats = client.stats.country_players(rank_type=StatsRankType.OPEN) + for country in stats.stats[:5]: + print(f"{country.country_name}: {country.player_count} players") + + # Get women's rankings by country (using string for backwards compatibility) + women_stats = client.stats.country_players(rank_type="WOMEN") + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + + params: dict[str, Any] = {} + if rank_value != "OPEN": + params["rank_type"] = rank_value + + response = self._http._request("GET", "/stats/country_players", params=params) + return CountryPlayersResponse.model_validate(response) + + def state_players(self, rank_type: StatsRankType | str = "OPEN") -> StatePlayersResponse: + """Get player count statistics by state/province. + + Returns player counts for North American states and provinces. Includes + an "Unknown" entry for players without location data. + + Args: + rank_type: Ranking type - "OPEN" for all players or "WOMEN" for + women's rankings. Accepts StatsRankType enum or string. Defaults to "OPEN". + + Returns: + StatePlayersResponse with player counts for each state/province. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType + + # Get all states with player counts + stats = client.stats.state_players(rank_type=StatsRankType.OPEN) + for state in stats.stats[:5]: + print(f"{state.stateprov}: {state.player_count} players") + + # Filter to specific region via post-processing + west_coast = [s for s in stats.stats if s.stateprov in ["WA", "OR", "CA"]] + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + + params: dict[str, Any] = {} + if rank_value != "OPEN": + params["rank_type"] = rank_value + + response = self._http._request("GET", "/stats/state_players", params=params) + return StatePlayersResponse.model_validate(response) + + def state_tournaments( + self, rank_type: StatsRankType | str = "OPEN" + ) -> StateTournamentsResponse: + """Get tournament count and points statistics by state/province. + + Returns detailed financial/points analysis by state including total + WPPR points awarded and tournament values. + + Args: + rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". + + Returns: + StateTournamentsResponse with tournament counts and point totals. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType + + # Get tournament statistics by state + stats = client.stats.state_tournaments(rank_type=StatsRankType.OPEN) + for state in stats.stats[:5]: + print(f"{state.stateprov}: {state.tournament_count} tournaments") + print(f" Total Points: {state.total_points_all}") + print(f" Tournament Value: {state.total_points_tournament_value}") + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + + params: dict[str, Any] = {} + if rank_value != "OPEN": + params["rank_type"] = rank_value + + response = self._http._request("GET", "/stats/state_tournaments", params=params) + return StateTournamentsResponse.model_validate(response) + + def events_by_year( + self, + rank_type: StatsRankType | str = "OPEN", + country_code: str | None = None, + ) -> EventsByYearResponse: + """Get statistics about number of events per year. + + Shows yearly growth trends in international pinball competition including + country participation, tournament counts, and player activity. + + Args: + rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". + country_code: Optional country code to filter by (e.g., "US", "CA"). + + Returns: + EventsByYearResponse with yearly statistics, sorted by year descending. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType + + # Get global events by year + stats = client.stats.events_by_year(rank_type=StatsRankType.OPEN) + for year in stats.stats[:5]: + print(f"{year.year}: {year.tournament_count} tournaments") + print(f" Countries: {year.country_count}") + print(f" Players: {year.player_count}") + + # Get US-specific data + us_stats = client.stats.events_by_year(country_code="US") + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + + params: dict[str, Any] = {} + if rank_value != "OPEN": + params["rank_type"] = rank_value + if country_code is not None: + params["country_code"] = country_code + + response = self._http._request("GET", "/stats/events_by_year", params=params) + return EventsByYearResponse.model_validate(response) + + def players_by_year(self) -> PlayersByYearResponse: + """Get statistics about number of players per year. + + This unique endpoint tracks player retention across multiple years, + showing how many players were active in consecutive years. Great for + analyzing player retention trends. + + Returns: + PlayersByYearResponse with yearly player retention statistics. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + # Get player retention statistics + stats = client.stats.players_by_year() + for year in stats.stats[:5]: + print(f"{year.year}:") + print(f" Current year: {year.current_year_count} players") + print(f" Also active previous year: {year.previous_year_count}") + print(f" Also active 2 years prior: {year.previous_2_year_count}") + + # Calculate retention rate + recent = stats.stats[0] + retention = (recent.previous_year_count / recent.current_year_count) * 100 + print(f"Year-over-year retention: {retention:.1f}%") + ``` + """ + response = self._http._request("GET", "/stats/players_by_year") + return PlayersByYearResponse.model_validate(response) + + def largest_tournaments( + self, + rank_type: StatsRankType | str = "OPEN", + country_code: str | None = None, + ) -> LargestTournamentsResponse: + """Get top 25 tournaments by player count. + + Returns the largest tournaments in IFPA history, sorted by number of + participants. + + Args: + rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". + country_code: Optional country code to filter by (e.g., "US", "CA"). + + Returns: + LargestTournamentsResponse with top 25 tournaments by player count. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType + + # Get largest tournaments globally + stats = client.stats.largest_tournaments(rank_type=StatsRankType.OPEN) + for tourney in stats.stats[:10]: + print(f"{tourney.tournament_name} ({tourney.tournament_date})") + print(f" {tourney.player_count} players") + print(f" {tourney.country_name}") + + # Get largest US tournaments + us_stats = client.stats.largest_tournaments(country_code="US") + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + + params: dict[str, Any] = {} + if rank_value != "OPEN": + params["rank_type"] = rank_value + if country_code is not None: + params["country_code"] = country_code + + response = self._http._request("GET", "/stats/largest_tournaments", params=params) + return LargestTournamentsResponse.model_validate(response) + + def lucrative_tournaments( + self, + rank_type: StatsRankType | str = "OPEN", + major: MajorTournament | str = "Y", + country_code: str | None = None, + ) -> LucrativeTournamentsResponse: + """Get top 25 tournaments by tournament value (WPPR rating). + + Returns the highest-value tournaments, which typically correlate with + the most competitive and prestigious events. + + Args: + rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". + major: Filter by major tournament status - "Y" for major tournaments + only (default), "N" for non-major tournaments. Accepts MajorTournament + enum or string. + country_code: Optional country code to filter by (e.g., "US", "CA"). + + Returns: + LucrativeTournamentsResponse with top 25 tournaments by value. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType, MajorTournament + + # Get highest-value major tournaments (using enums) + stats = client.stats.lucrative_tournaments( + rank_type=StatsRankType.OPEN, + major=MajorTournament.YES + ) + for tourney in stats.stats[:10]: + print(f"{tourney.tournament_name} ({tourney.tournament_date})") + print(f" Value: {tourney.tournament_value}") + print(f" {tourney.country_name}") + + # Get highest-value non-major tournaments (using strings) + non_major = client.stats.lucrative_tournaments(major="N") + + # Filter by country + us_major = client.stats.lucrative_tournaments(country_code="US") + ``` + """ + # Extract enum values if enums passed, otherwise use strings directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + major_value = major.value if isinstance(major, MajorTournament) else major + + params: dict[str, Any] = {} + if major_value != "Y": + params["major"] = major_value + if rank_value != "OPEN": + params["rank_type"] = rank_value + if country_code is not None: + params["country_code"] = country_code + + response = self._http._request("GET", "/stats/lucrative_tournaments", params=params) + return LucrativeTournamentsResponse.model_validate(response) + + def points_given_period( + self, + rank_type: StatsRankType | str = "OPEN", + country_code: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + limit: int | None = None, + ) -> PointsGivenPeriodResponse: + """Get players with total accumulative points over a time period. + + Returns top point earners for a date range, useful for identifying + the most successful players during specific time windows. + + Args: + rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". + country_code: Optional country code to filter by (e.g., "US", "CA"). + start_date: Start date in YYYY-MM-DD format. If not provided, API + uses a default lookback period. + end_date: End date in YYYY-MM-DD format. If not provided, API uses + current date. + limit: Maximum number of results to return. API default applies if + not specified. + + Returns: + PointsGivenPeriodResponse with top point earners for the period. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType + + # Get top point earners for 2024 + stats = client.stats.points_given_period( + rank_type=StatsRankType.OPEN, + start_date="2024-01-01", + end_date="2024-12-31", + limit=25 + ) + for player in stats.stats: + print(f"{player.first_name} {player.last_name}: {player.wppr_points} pts") + + # Get top US point earners + us_stats = client.stats.points_given_period( + country_code="US", + start_date="2024-01-01", + limit=10 + ) + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + + params: dict[str, Any] = {} + if rank_value != "OPEN": + params["rank_type"] = rank_value + if country_code is not None: + params["country_code"] = country_code + if start_date is not None: + params["start_date"] = start_date + if end_date is not None: + params["end_date"] = end_date + if limit is not None: + params["limit"] = limit + + response = self._http._request("GET", "/stats/points_given_period", params=params) + return PointsGivenPeriodResponse.model_validate(response) + + def events_attended_period( + self, + rank_type: StatsRankType | str = "OPEN", + country_code: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + limit: int | None = None, + ) -> EventsAttendedPeriodResponse: + """Get players with total accumulative events over a time period. + + Returns most active players by tournament attendance for a date range, + useful for identifying the most dedicated competitors. + + Args: + rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". + country_code: Optional country code to filter by (e.g., "US", "CA"). + start_date: Start date in YYYY-MM-DD format. If not provided, API + uses a default lookback period. + end_date: End date in YYYY-MM-DD format. If not provided, API uses + current date. + limit: Maximum number of results to return. API default applies if + not specified. + + Returns: + EventsAttendedPeriodResponse with most active players for the period. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + from ifpa_api import StatsRankType + + # Get most active players in 2024 + stats = client.stats.events_attended_period( + rank_type=StatsRankType.OPEN, + start_date="2024-01-01", + end_date="2024-12-31", + limit=25 + ) + for player in stats.stats: + name = f"{player.first_name} {player.last_name}" + print(f"{name}: {player.tournament_count} tournaments") + + # Get most active US players + us_stats = client.stats.events_attended_period( + country_code="US", + start_date="2024-01-01", + limit=10 + ) + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + rank_value = rank_type.value if isinstance(rank_type, StatsRankType) else rank_type + + params: dict[str, Any] = {} + if rank_value != "OPEN": + params["rank_type"] = rank_value + if country_code is not None: + params["country_code"] = country_code + if start_date is not None: + params["start_date"] = start_date + if end_date is not None: + params["end_date"] = end_date + if limit is not None: + params["limit"] = limit + + response = self._http._request("GET", "/stats/events_attended_period", params=params) + return EventsAttendedPeriodResponse.model_validate(response) + + def overall(self, system_code: SystemCode | str = "OPEN") -> OverallStatsResponse: + """Get overall WPPR system statistics. + + Returns aggregate statistics about the entire IFPA system including + total player counts, tournament counts, and age distribution. + + Note that this endpoint returns proper numeric types (int/float) unlike + other stats endpoints which return strings. + + Args: + system_code: Ranking system - "OPEN" for open division or "WOMEN" + for women's division. Accepts SystemCode enum or string. Defaults to "OPEN". + + Returns: + OverallStatsResponse with comprehensive IFPA system statistics. + + Raises: + IfpaApiError: If the API request fails. + + Note: + As of 2025-11-19, the API appears to have a bug where system_code="WOMEN" + returns OPEN data. This is an API limitation, not a client issue. + + Example: + ```python + from ifpa_api import SystemCode + + # Get overall IFPA statistics (using enum) + stats = client.stats.overall(system_code=SystemCode.OPEN) + print(f"Total players: {stats.stats.overall_player_count}") + print(f"Active players: {stats.stats.active_player_count}") + print(f"Total tournaments: {stats.stats.tournament_count}") + print(f"Tournaments this year: {stats.stats.tournament_count_this_year}") + print(f"Avg players/tournament: {stats.stats.tournament_player_count_average}") + + # Age distribution + age = stats.stats.age + print(f"Under 18: {age.age_under_18}%") + print(f"18-29: {age.age_18_to_29}%") + print(f"30-39: {age.age_30_to_39}%") + print(f"40-49: {age.age_40_to_49}%") + print(f"50+: {age.age_50_to_99}%") + ``` + """ + # Extract enum value if enum passed, otherwise use string directly + system_value = system_code.value if isinstance(system_code, SystemCode) else system_code + + params: dict[str, Any] = {} + if system_value != "OPEN": + params["system_code"] = system_value + + response = self._http._request("GET", "/stats/overall", params=params) + return OverallStatsResponse.model_validate(response) diff --git a/src/ifpa_api/resources/tournament/query_builder.py b/src/ifpa_api/resources/tournament/query_builder.py index daa9ed5..5a970d9 100644 --- a/src/ifpa_api/resources/tournament/query_builder.py +++ b/src/ifpa_api/resources/tournament/query_builder.py @@ -14,6 +14,7 @@ from ifpa_api.core.base import LocationFiltersMixin, PaginationMixin from ifpa_api.core.exceptions import IfpaClientValidationError from ifpa_api.core.query_builder import QueryBuilder +from ifpa_api.models.common import TournamentSearchType from ifpa_api.models.tournaments import TournamentSearchResponse if TYPE_CHECKING: @@ -130,22 +131,39 @@ def date_range(self, start_date: str | None, end_date: str | None) -> Self: clone._params["end_date"] = end_date return clone - def tournament_type(self, tournament_type: str) -> Self: + def tournament_type(self, tournament_type: TournamentSearchType | str) -> Self: """Filter by tournament type. Args: - tournament_type: Tournament type (e.g., "open", "women", "youth") + tournament_type: Tournament type filter (open, women, youth, league). + Can be string or TournamentSearchType enum. Returns: New TournamentQueryBuilder instance with tournament type filter applied Example: ```python - results = client.tournament.query().tournament_type("women").get() + from ifpa_api import TournamentSearchType + + # Using enum (preferred) + results = (client.tournament.search() + .tournament_type(TournamentSearchType.WOMEN) + .get()) + + # Using string (backwards compatible) + results = (client.tournament.search() + .tournament_type("women") + .get()) ``` """ clone = self._clone() - clone._params["tournament_type"] = tournament_type + # Extract enum value if enum is passed + type_value = ( + tournament_type.value + if isinstance(tournament_type, TournamentSearchType) + else tournament_type + ) + clone._params["tournament_type"] = type_value return clone def get(self) -> TournamentSearchResponse: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1237ec9..45291b7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,8 @@ """Fixtures for integration tests.""" from collections.abc import Generator +from datetime import datetime, timedelta +from typing import Any import pytest @@ -224,3 +226,232 @@ def test_tournament_feature(client): ) # Re-raise unexpected errors raise + + +# === STATS-SPECIFIC HELPERS === + + +def assert_stats_fields_types(obj: object, field_types: dict[str, type]) -> None: + """Assert multiple stats fields have correct types. + + Helper function to reduce boilerplate when validating many fields at once. + This is particularly useful for stats endpoints that return objects with + 10+ fields that need type validation. + + Args: + obj: Object to validate + field_types: Dictionary mapping field names to expected types + + Raises: + AssertionError: If any field is missing, None, or wrong type + + Example: + ```python + assert_stats_fields_types(stats, { + "overall_player_count": int, + "active_player_count": int, + "tournament_count": int, + "wppr_value": float, + }) + ``` + """ + for field_name, expected_type in field_types.items(): + assert_field_present(obj, field_name, expected_type) + + +def assert_stats_ranking_list(rankings: list[Any], min_count: int = 1) -> None: + """Assert that a stats ranking list is valid. + + Validates: + - List is not empty (unless min_count=0) + - List has at least min_count items + - All items have a stats_rank field + - Ranks are in ascending order (1, 2, 3...) + + Args: + rankings: List of ranking objects to validate + min_count: Minimum expected number of items (default 1) + + Raises: + AssertionError: If list is invalid or ranks are out of order + + Example: + ```python + result = client.stats.country_players() + assert_stats_ranking_list(result.stats, min_count=10) + ``` + """ + assert isinstance(rankings, list), f"Expected list, got {type(rankings)}" + assert ( + len(rankings) >= min_count + ), f"Expected at least {min_count} rankings, got {len(rankings)}" + + if len(rankings) > 0: + # Validate first item has stats_rank field + first_item = rankings[0] + assert hasattr(first_item, "stats_rank"), "Ranking item missing 'stats_rank' field" + + # Validate ranks are sequential + for i, item in enumerate(rankings, start=1): + expected_rank = i + actual_rank = int(item.stats_rank) + assert actual_rank == expected_rank, ( + f"Rank out of order at position {i}: " + f"expected {expected_rank}, got {actual_rank}" + ) + + +def assert_numeric_in_range( + value: int | float, min_val: int | float, max_val: int | float, field_name: str = "value" +) -> None: + """Assert that a numeric value is within an expected range. + + Args: + value: Value to check + min_val: Minimum acceptable value (inclusive) + max_val: Maximum acceptable value (inclusive) + field_name: Name of the field for error messages + + Raises: + AssertionError: If value is outside range + + Example: + ```python + assert_numeric_in_range(stats.overall_player_count, 100000, 200000, "overall_player_count") + ``` + """ + assert min_val <= value <= max_val, ( + f"{field_name} out of expected range: {value} " f"(expected {min_val}-{max_val})" + ) + + +# === STATS FIXTURES === + + +@pytest.fixture +def stats_date_range_90_days() -> tuple[str, str]: + """Date range for last 90 days (period-based stats queries). + + Returns: + Tuple of (start_date, end_date) in YYYY-MM-DD format + + Use this for testing period-based endpoints like points_given_period + and events_attended_period. 90 days provides good balance of recent + data without being too restrictive. + + Example: + ```python + def test_points_period(client, stats_date_range_90_days): + start, end = stats_date_range_90_days + result = client.stats.points_given_period(start, end) + assert result is not None + ``` + """ + end_date = datetime.now() + start_date = end_date - timedelta(days=90) + return (start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d")) + + +@pytest.fixture +def stats_date_range_180_days() -> tuple[str, str]: + """Date range for last 180 days (extended period stats queries). + + Returns: + Tuple of (start_date, end_date) in YYYY-MM-DD format + + Use this for testing period queries that may have sparse data or when + you need more results. 180 days captures seasonal patterns. + + Example: + ```python + def test_events_attended_longer_period(client, stats_date_range_180_days): + start, end = stats_date_range_180_days + result = client.stats.events_attended_period(start, end) + assert len(result.rankings) > 0 + ``` + """ + end_date = datetime.now() + start_date = end_date - timedelta(days=180) + return (start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d")) + + +@pytest.fixture +def stats_date_range_last_year() -> tuple[str, str]: + """Date range for last full year (annual stats queries). + + Returns: + Tuple of (start_date, end_date) in YYYY-MM-DD format + + Use this for testing with a full year of data, useful for annual + trends and comparing across seasons. + + Example: + ```python + def test_annual_activity(client, stats_date_range_last_year): + start, end = stats_date_range_last_year + result = client.stats.points_given_period(start, end) + assert len(result.rankings) >= 100 + ``` + """ + end_date = datetime.now() + start_date = end_date - timedelta(days=365) + return (start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d")) + + +@pytest.fixture +def stats_thresholds() -> dict[str, int | str]: + """Expected minimum thresholds for IFPA overall statistics. + + Returns: + Dictionary of statistic names to minimum expected values + + These thresholds are based on IFPA's size as of 2025-11 (100,000+ players, + 2,000+ tournaments per year). + + **Review Schedule**: Update thresholds annually or when IFPA announces + significant platform changes. + + If tests fail due to threshold violations, check IFPA's current statistics + and update these values accordingly. + + Example: + ```python + def test_overall_stats_reasonable(client, stats_thresholds): + stats = client.stats.overall() + assert stats.overall_player_count >= stats_thresholds["overall_player_count"] + assert stats.tournament_count >= stats_thresholds["tournament_count"] + ``` + """ + return { + "_last_updated": "2025-11", # Metadata: when thresholds were set + "_source": "IFPA stats as of November 2025", + "overall_player_count": 100000, # 100k+ registered players + "active_player_count": 20000, # 20k+ active in last 2 years + "tournament_count": 20000, # 20k+ total tournaments + "tournament_count_this_year": 500, # 500+ tournaments this year + "tournament_count_last_month": 30, # 30+ tournaments (lowered for seasonal variation) + } + + +@pytest.fixture +def stats_rank_types() -> list[str]: + """List of rank types for parameterized testing. + + Returns: + List ["OPEN", "WOMEN"] - the two primary IFPA ranking systems + + Use this for parameterized tests that need to run against both + ranking systems. + + Note: "WOMEN" system has a known API bug in overall() endpoint + where system_code is always "OPEN" regardless of parameter. + + Example: + ```python + @pytest.mark.parametrize("rank_type", ["OPEN", "WOMEN"]) + def test_country_players(client, rank_type): + result = client.stats.country_players(rank_type=rank_type) + assert result.rank_type == rank_type + ``` + """ + return ["OPEN", "WOMEN"] diff --git a/tests/integration/test_rankings_integration.py b/tests/integration/test_rankings_integration.py index afe69f0..004bb1d 100644 --- a/tests/integration/test_rankings_integration.py +++ b/tests/integration/test_rankings_integration.py @@ -22,7 +22,7 @@ import pytest from pydantic import ValidationError -from ifpa_api import IfpaClient +from ifpa_api import IfpaClient, RankingDivision from ifpa_api.core.exceptions import IfpaApiError from ifpa_api.models.rankings import ( CountryRankingsResponse, @@ -312,6 +312,26 @@ def test_women_country_filter(self, api_key: str) -> None: assert isinstance(result, RankingsResponse) assert len(result.rankings) > 0 + def test_women_with_enum_open(self, api_key: str) -> None: + """Test women() with RankingDivision.OPEN enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + result = client.rankings.women(tournament_type=RankingDivision.OPEN, count=25) + + assert isinstance(result, RankingsResponse) + assert len(result.rankings) > 0 + assert result.rankings[0].player_id is not None + + def test_women_with_enum_women(self, api_key: str) -> None: + """Test women() with RankingDivision.WOMEN enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + result = client.rankings.women(tournament_type=RankingDivision.WOMEN, count=25) + + assert isinstance(result, RankingsResponse) + assert len(result.rankings) > 0 + assert result.rankings[0].player_id is not None + # ============================================================================= # YOUTH RANKINGS @@ -474,6 +494,25 @@ def test_pro_pagination(self, api_key: str) -> None: # API doesn't respect start_pos/count, just verify we get results assert len(result.rankings) > 0 + def test_pro_with_enum_open(self, api_key: str) -> None: + """Test pro() with RankingDivision.OPEN enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + result = client.rankings.pro(ranking_system=RankingDivision.OPEN, count=25) + + assert isinstance(result, RankingsResponse) + assert len(result.rankings) > 0 + assert result.rankings[0].player_id is not None + + def test_pro_with_enum_women(self, api_key: str) -> None: + """Test pro() with RankingDivision.WOMEN enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + result = client.rankings.pro(ranking_system=RankingDivision.WOMEN, count=25) + + assert isinstance(result, RankingsResponse) + assert len(result.rankings) > 0 + # ============================================================================= # COUNTRY RANKINGS diff --git a/tests/integration/test_stats_integration.py b/tests/integration/test_stats_integration.py new file mode 100644 index 0000000..c1c0213 --- /dev/null +++ b/tests/integration/test_stats_integration.py @@ -0,0 +1,876 @@ +"""Integration tests for Stats resource. + +This test suite performs comprehensive integration testing of all Stats resource methods +against the live IFPA API. Tests cover all 10 statistical endpoints with real data validation. + +Test Categories: +1. TestStatsGeographicData - Geographic statistics (country/state players, + state tournaments) +2. TestStatsHistoricalTrends - Historical trends (events_by_year, players_by_year) +3. TestStatsTournamentRankings - Tournament rankings (largest, lucrative tournaments) +4. TestStatsPlayerActivity - Player activity in date ranges (points_given, + events_attended) +5. TestStatsOverall - Overall IFPA statistics +6. TestStatsDataQualityAndErrors - Data quality validation and error handling + +All endpoints were verified operational as of 2025-11-19 via curl testing. + +These tests make real API calls and require a valid API key. +Run with: pytest -m integration tests/integration/test_stats_integration.py +""" + +from decimal import Decimal + +import pytest + +from ifpa_api import IfpaClient +from ifpa_api.core.exceptions import IfpaApiError +from ifpa_api.models.common import MajorTournament, StatsRankType, SystemCode +from ifpa_api.models.stats import ( + CountryPlayersResponse, + EventsAttendedPeriodResponse, + EventsByYearResponse, + LargestTournamentsResponse, + LucrativeTournamentsResponse, + OverallStatsResponse, + PlayersByYearResponse, + PointsGivenPeriodResponse, + PointsGivenPeriodStat, + StatePlayersResponse, + StateTournamentsResponse, +) +from tests.integration.conftest import ( + assert_numeric_in_range, + assert_stats_fields_types, + assert_stats_ranking_list, +) + +# ============================================================================= +# GEOGRAPHIC STATISTICS +# ============================================================================= + + +@pytest.mark.integration +class TestStatsGeographicData: + """Test geographic statistics endpoints (country/state-based data).""" + + @pytest.mark.parametrize("rank_type", ["OPEN", "WOMEN"]) + def test_country_players_by_rank_type(self, client: IfpaClient, rank_type: str) -> None: + """Test country_players() with different ranking systems. + + Args: + client: IFPA API client fixture + rank_type: Ranking system to test (OPEN or WOMEN) + """ + result = client.stats.country_players(rank_type=rank_type) + + # Validate response structure + assert isinstance(result, CountryPlayersResponse) + assert result.type == "Players by Country" + assert result.rank_type == rank_type + assert len(result.stats) > 0 + + # Validate first entry structure using helper + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "country_name": str, + "country_code": str, + "player_count": int, + "stats_rank": int, + }, + ) + assert first_stat.player_count > 0 + assert first_stat.stats_rank == 1 # First entry should be rank 1 + + def test_state_players(self, client: IfpaClient) -> None: + """Test state_players() returns North American state/province data. + + Args: + client: IFPA API client fixture + """ + result = client.stats.state_players() + + # Validate response structure + assert isinstance(result, StatePlayersResponse) + assert "Players by State" in result.type # API may add "(North America)" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify US states are included + state_codes = [stat.stateprov for stat in result.stats] + common_states = ["CA", "NY", "TX", "WA"] + assert any( + state in state_codes for state in common_states + ), "Expected common US states in results" + + # Validate structure using helper + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "stateprov": str, + "player_count": int, + "stats_rank": int, + }, + ) + assert first_stat.player_count > 0 + assert first_stat.stats_rank == 1 + + @pytest.mark.parametrize("rank_type", ["OPEN", "WOMEN"]) + def test_state_tournaments_by_rank_type(self, client: IfpaClient, rank_type: str) -> None: + """Test state_tournaments() with different ranking systems. + + Args: + client: IFPA API client fixture + rank_type: Ranking system to test (OPEN or WOMEN) + """ + result = client.stats.state_tournaments(rank_type=rank_type) + + # Validate response structure + assert isinstance(result, StateTournamentsResponse) + assert "Tournaments by State" in result.type + assert result.rank_type == rank_type + assert len(result.stats) > 0 + + # Validate detailed tournament statistics + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "stateprov": str, + "tournament_count": int, + "total_points_all": Decimal, + "total_points_tournament_value": Decimal, + "stats_rank": int, + }, + ) + assert first_stat.tournament_count > 0 + assert first_stat.total_points_all > 0 + assert first_stat.stats_rank == 1 + + +# ============================================================================= +# HISTORICAL TRENDS STATISTICS +# ============================================================================= + + +@pytest.mark.integration +class TestStatsHistoricalTrends: + """Test historical trend statistics endpoints.""" + + @pytest.mark.parametrize("country_code", [None, "US"]) + def test_events_by_year(self, client: IfpaClient, country_code: str | None) -> None: + """Test events_by_year() with optional country filter. + + Args: + client: IFPA API client fixture + country_code: Optional country code filter + """ + result = client.stats.events_by_year(country_code=country_code) + + # Validate response structure + assert isinstance(result, EventsByYearResponse) + assert "Events" in result.type and "Year" in result.type + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Validate historical data structure + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "year": str, + "country_count": int, + "tournament_count": int, + "player_count": int, + }, + ) + assert first_stat.country_count > 0 + assert first_stat.tournament_count > 0 + assert first_stat.player_count > 0 + + def test_players_by_year(self, client: IfpaClient) -> None: + """Test players_by_year() returns player retention metrics. + + Args: + client: IFPA API client fixture + """ + result = client.stats.players_by_year() + + # Validate response structure + assert isinstance(result, PlayersByYearResponse) + assert result.type == "Players by Year" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Validate retention metrics structure + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "year": str, + "current_year_count": int, + "previous_year_count": int, + "previous_2_year_count": int, + }, + ) + assert first_stat.current_year_count > 0 + # Retention counts should be less than or equal to current year + assert first_stat.previous_year_count <= first_stat.current_year_count + + +# ============================================================================= +# TOURNAMENT RANKINGS STATISTICS +# ============================================================================= + + +@pytest.mark.integration +class TestStatsTournamentRankings: + """Test tournament ranking statistics endpoints.""" + + @pytest.mark.parametrize("rank_type", ["OPEN", "WOMEN"]) + def test_largest_tournaments_by_rank_type(self, client: IfpaClient, rank_type: str) -> None: + """Test largest_tournaments() with different ranking systems. + + Args: + client: IFPA API client fixture + rank_type: Ranking system to test (OPEN or WOMEN) + """ + result = client.stats.largest_tournaments(rank_type=rank_type) + + # Validate response structure + assert isinstance(result, LargestTournamentsResponse) + assert result.type == "Largest Tournaments" + assert result.rank_type == rank_type + assert len(result.stats) > 0 + assert len(result.stats) <= 25 # API returns top 25 + + # Validate tournament metadata + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "tournament_id": int, + "tournament_name": str, + "event_name": str, + "country_name": str, + "country_code": str, + "player_count": int, + "tournament_date": str, + "stats_rank": int, + }, + ) + assert first_stat.player_count > 0 + assert first_stat.stats_rank == 1 + + @pytest.mark.parametrize( + ("rank_type", "major"), + [ + ("OPEN", "Y"), # Default: OPEN majors + ("OPEN", "N"), # OPEN non-majors + ("WOMEN", "Y"), # WOMEN majors + ], + ) + def test_lucrative_tournaments(self, client: IfpaClient, rank_type: str, major: str) -> None: + """Test lucrative_tournaments() with rank types and major filter. + + Args: + client: IFPA API client fixture + rank_type: Ranking system to test (OPEN or WOMEN) + major: Major tournament filter ("Y" or "N") + """ + result = client.stats.lucrative_tournaments(rank_type=rank_type, major=major) + + # Validate response structure + assert isinstance(result, LucrativeTournamentsResponse) + assert result.type == "Lucrative Tournaments" + assert result.rank_type == rank_type + assert len(result.stats) > 0 + + # Validate tournament value fields + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "tournament_id": int, + "tournament_name": str, + "tournament_value": float, + "stats_rank": int, + }, + ) + assert first_stat.tournament_value > 0 + assert first_stat.stats_rank == 1 + + +# ============================================================================= +# PLAYER ACTIVITY PERIOD STATISTICS +# ============================================================================= + + +@pytest.mark.integration +class TestStatsPlayerActivity: + """Test player activity statistics over time periods.""" + + @pytest.mark.parametrize( + "date_fixture_name", + ["stats_date_range_90_days", "stats_date_range_180_days"], + ) + def test_points_given_period( + self, client: IfpaClient, date_fixture_name: str, request: pytest.FixtureRequest + ) -> None: + """Test points_given_period() with different date ranges. + + Args: + client: IFPA API client fixture + date_fixture_name: Name of date fixture to use + request: Pytest fixture request object + """ + # Get the date range fixture dynamically + start_date, end_date = request.getfixturevalue(date_fixture_name) + + result = client.stats.points_given_period( + start_date=start_date, end_date=end_date, limit=10 + ) + + # Validate response structure + assert isinstance(result, PointsGivenPeriodResponse) + assert "Points" in result.type + assert result.start_date is not None + assert result.end_date is not None + assert result.return_count >= 0 + assert result.rank_type == "OPEN" + + # May be empty if no tournaments in date range, but structure should be valid + if len(result.stats) > 0: + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "player_id": int, + "first_name": str, + "last_name": str, + "country_name": str, + "wppr_points": Decimal, + "stats_rank": int, + }, + ) + assert first_stat.wppr_points > 0 + assert first_stat.stats_rank == 1 + + def test_points_given_period_with_country_filter( + self, client: IfpaClient, stats_date_range_180_days: tuple[str, str], country_code: str + ) -> None: + """Test points_given_period() with country filter. + + Args: + client: IFPA API client fixture + stats_date_range_180_days: 180-day date range fixture + country_code: Country code from fixture + """ + start_date, end_date = stats_date_range_180_days + + result = client.stats.points_given_period( + start_date=start_date, + end_date=end_date, + country_code=country_code, + limit=10, + ) + + # Validate response structure + assert isinstance(result, PointsGivenPeriodResponse) + # If results exist, verify structure (country filter may not work perfectly in API) + if len(result.stats) > 0: + assert result.stats[0].country_code is not None + + def test_events_attended_period( + self, client: IfpaClient, stats_date_range_90_days: tuple[str, str] + ) -> None: + """Test events_attended_period() with 90-day date range. + + Args: + client: IFPA API client fixture + stats_date_range_90_days: 90-day date range fixture + """ + start_date, end_date = stats_date_range_90_days + + result = client.stats.events_attended_period( + start_date=start_date, end_date=end_date, limit=10 + ) + + # Validate response structure + assert isinstance(result, EventsAttendedPeriodResponse) + assert "Events" in result.type or "Tournaments" in result.type + assert result.start_date is not None + assert result.end_date is not None + assert result.return_count >= 0 + + # Note: This endpoint doesn't include rank_type field + # May be empty if no tournaments in date range + if len(result.stats) > 0: + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "player_id": int, + "first_name": str, + "last_name": str, + "tournament_count": int, + "stats_rank": int, + }, + ) + assert first_stat.tournament_count > 0 + assert first_stat.stats_rank == 1 + + def test_recent_activity_smoke_test( + self, client: IfpaClient, stats_date_range_180_days: tuple[str, str] + ) -> None: + """Sanity check: IFPA should have activity in last 180 days. + + This test catches data availability issues that might make all + period tests pass with empty results. + + Args: + client: IFPA API client fixture + stats_date_range_180_days: 180-day date range fixture + """ + start_date, end_date = stats_date_range_180_days + result = client.stats.points_given_period( + start_date=start_date, end_date=end_date, limit=100 + ) + + assert len(result.stats) > 0, ( + f"No WPPR points awarded in 180 days ({start_date} to {end_date}). " + "This suggests IFPA data issue or API downtime." + ) + assert isinstance(result.stats[0], PointsGivenPeriodStat) + + def test_period_endpoints_smoke_test( + self, client: IfpaClient, stats_date_range_90_days: tuple[str, str] + ) -> None: + """Quick smoke test for period-based endpoints with last 90 days. + + Validates that period endpoints work and return reasonable data + for a recent, small date range. + + Args: + client: IFPA API client fixture + stats_date_range_90_days: 90-day date range fixture + """ + # Use last 90 days from fixture + start_date, end_date = stats_date_range_90_days + + # Test 1: Points given period + points_result = client.stats.points_given_period( + start_date=start_date, end_date=end_date, limit=10 + ) + assert isinstance(points_result, PointsGivenPeriodResponse) + assert points_result.start_date == start_date + assert points_result.end_date == end_date + # May be empty if no tournaments in period, just check structure + assert isinstance(points_result.stats, list) + + # Test 2: Events attended period + events_result = client.stats.events_attended_period( + start_date=start_date, end_date=end_date, limit=10 + ) + assert isinstance(events_result, EventsAttendedPeriodResponse) + assert events_result.start_date == start_date + assert events_result.end_date == end_date + # May be empty if no events in period, just check structure + assert isinstance(events_result.stats, list) + + +# ============================================================================= +# OVERALL STATISTICS +# ============================================================================= + + +@pytest.mark.integration +class TestStatsOverall: + """Test overall IFPA statistics endpoint.""" + + @pytest.mark.parametrize("system_code", ["OPEN", "WOMEN"]) + def test_overall_stats( + self, + client: IfpaClient, + system_code: str, + stats_thresholds: dict[str, int], + ) -> None: + """Test overall() with different system codes. + + Note: As of 2025-11-19, the API has a bug where system_code=WOMEN + returns OPEN data. This test documents the current behavior. + + Args: + client: IFPA API client fixture + system_code: System code to test (OPEN or WOMEN) + stats_thresholds: Expected minimum thresholds fixture + """ + result = client.stats.overall(system_code=system_code) + + # Validate response structure + assert isinstance(result, OverallStatsResponse) + assert result.type == "Overall Stats" + + # API bug: Always returns OPEN regardless of system_code + if system_code == "WOMEN": + assert result.system_code == "OPEN" # Known bug - document it + else: + assert result.system_code == "OPEN" + + # Validate nested stats object (not an array) using helper + stats = result.stats + assert_stats_fields_types( + stats, + { + "overall_player_count": int, + "active_player_count": int, + "tournament_count": int, + "tournament_count_last_month": int, + "tournament_count_this_year": int, + "tournament_player_count": int, + "tournament_player_count_average": float, + }, + ) + + # Verify counts are within reasonable ranges using thresholds + assert_numeric_in_range( + stats.overall_player_count, + stats_thresholds["overall_player_count"], + 200000, # Upper bound + "overall_player_count", + ) + assert stats.active_player_count > stats_thresholds["active_player_count"] + assert stats.tournament_count > stats_thresholds["tournament_count"] + assert stats.tournament_count_this_year >= stats_thresholds["tournament_count_this_year"] + assert stats.tournament_count_last_month >= stats_thresholds["tournament_count_last_month"] + assert stats.tournament_player_count_average > 0 + + # Validate age distribution + age = stats.age + assert_stats_fields_types( + age, + { + "age_under_18": float, + "age_18_to_29": float, + "age_30_to_39": float, + "age_40_to_49": float, + "age_50_to_99": float, + }, + ) + + # All age percentages should be non-negative + assert age.age_under_18 >= 0 + assert age.age_18_to_29 >= 0 + assert age.age_30_to_39 >= 0 + assert age.age_40_to_49 >= 0 + assert age.age_50_to_99 >= 0 + + +# ============================================================================= +# DATA QUALITY AND ERROR HANDLING +# ============================================================================= + + +@pytest.mark.integration +class TestStatsDataQualityAndErrors: + """Test data quality validation and error handling for stats endpoints.""" + + # === DATA QUALITY TESTS === + + def test_country_players_sorted_and_ranked(self, client: IfpaClient) -> None: + """Verify country_players results are sorted correctly with sequential ranks.""" + result = client.stats.country_players() + assert_stats_ranking_list(result.stats, min_count=5) + + # Verify descending order by player count + for i in range(len(result.stats) - 1): + current_count = result.stats[i].player_count + next_count = result.stats[i + 1].player_count + assert ( + current_count >= next_count + ), f"Results not sorted: {current_count} < {next_count} at index {i}" + + def test_string_to_number_coercion(self, client: IfpaClient) -> None: + """Verify string count fields are properly coerced to numbers.""" + # Test various endpoints with string coercion + country_result = client.stats.country_players() + if len(country_result.stats) > 0: + assert isinstance(country_result.stats[0].player_count, int) + + state_result = client.stats.state_tournaments() + if len(state_result.stats) > 0: + assert isinstance(state_result.stats[0].tournament_count, int) + assert isinstance(state_result.stats[0].total_points_all, Decimal) + + largest_result = client.stats.largest_tournaments() + if len(largest_result.stats) > 0: + assert isinstance(largest_result.stats[0].player_count, int) + + def test_overall_stats_numeric_types(self, client: IfpaClient) -> None: + """Verify overall endpoint returns proper numeric types (not strings).""" + result = client.stats.overall() + + # Validate all numeric fields using helper + assert_stats_fields_types( + result.stats, + { + "overall_player_count": int, + "active_player_count": int, + "tournament_count": int, + "tournament_count_last_month": int, + "tournament_count_this_year": int, + "tournament_player_count": int, + "tournament_player_count_average": float, + }, + ) + + # Age percentages should be floats + assert_stats_fields_types( + result.stats.age, + { + "age_under_18": float, + "age_18_to_29": float, + }, + ) + + # === ERROR HANDLING TESTS === + + def test_invalid_date_range_handling(self, client: IfpaClient) -> None: + """Test that invalid/future date ranges are handled gracefully.""" + from datetime import datetime, timedelta + + # Future dates may return empty results (not an error) + future_start = (datetime.now() + timedelta(days=365)).strftime("%Y-%m-%d") + future_end = (datetime.now() + timedelta(days=730)).strftime("%Y-%m-%d") + + try: + result = client.stats.points_given_period( + start_date=future_start, end_date=future_end, limit=10 + ) + # Should succeed but may return no results + assert isinstance(result, PointsGivenPeriodResponse) + # Empty results are valid for future dates + except IfpaApiError as e: + # Some date validation errors may return 400 + assert e.status_code in (400, 404) + + def test_empty_period_results_handling(self, client: IfpaClient) -> None: + """Test handling of period queries that return no results.""" + # Use a very narrow date range that may have no tournaments + single_day = "2020-01-01" + + result = client.stats.events_attended_period( + start_date=single_day, end_date=single_day, limit=10 + ) + + assert isinstance(result, EventsAttendedPeriodResponse) + # Empty stats array is valid + assert result.return_count >= 0 + assert isinstance(result.stats, list) + + # === COMPREHENSIVE ERROR TESTS FOR ALL ENDPOINTS === + + def test_country_players_invalid_rank_type(self, client: IfpaClient) -> None: + """Test country_players() with invalid rank_type.""" + with pytest.raises((IfpaApiError, ValueError)): + client.stats.country_players(rank_type="INVALID") + + def test_events_by_year_invalid_country(self, client: IfpaClient) -> None: + """Test events_by_year() with invalid country code.""" + # API may accept invalid country codes and return empty results + result = client.stats.events_by_year(country_code="INVALID") + # Should not raise error, but may return empty or all results + assert isinstance(result, EventsByYearResponse) + + def test_largest_tournaments_invalid_rank_type(self, client: IfpaClient) -> None: + """Test largest_tournaments() with invalid rank_type.""" + with pytest.raises((IfpaApiError, ValueError)): + client.stats.largest_tournaments(rank_type="INVALID") + + def test_overall_invalid_system_code(self, client: IfpaClient) -> None: + """Test overall() with invalid system_code. + + Note: The API doesn't validate system_code and returns OPEN data + for any invalid value. This test documents that behavior. + """ + # API accepts any system_code but returns OPEN data + result = client.stats.overall(system_code="INVALID") + assert isinstance(result, OverallStatsResponse) + assert result.system_code == "OPEN" # API returns OPEN for invalid codes + + +# ============================================================================= +# PARAMETER VALIDATION +# ============================================================================= + + +@pytest.mark.integration +class TestStatsParameterValidation: + """Test parameter validation behavior.""" + + def test_country_players_with_invalid_rank_type(self, client: IfpaClient) -> None: + """Test that invalid rank_type is handled appropriately. + + Note: SDK does not validate rank_type client-side. API will reject it. + """ + with pytest.raises((IfpaApiError, ValueError)): + client.stats.country_players(rank_type="INVALID") + + def test_overall_with_invalid_system_code(self, client: IfpaClient) -> None: + """Test that invalid system_code is handled appropriately. + + Note: As of 2025-11, API accepts any system_code and returns OPEN data. + """ + result = client.stats.overall(system_code="INVALID") + assert isinstance(result, OverallStatsResponse) + # API bug: Returns OPEN data regardless of system_code + assert result.system_code == "OPEN" + + +# ============================================================================= +# ENUM PARAMETER SUPPORT +# ============================================================================= + + +@pytest.mark.integration +class TestStatsEnumSupport: + """Test enum parameter support with real API calls.""" + + def test_country_players_with_enum(self, client: IfpaClient) -> None: + """Test country_players with StatsRankType enum.""" + # Use enum parameter + result = client.stats.country_players(rank_type=StatsRankType.WOMEN) + + # Verify response + assert isinstance(result, CountryPlayersResponse) + assert result.type == "Players by Country" + assert result.rank_type == "WOMEN" + assert len(result.stats) > 0 + + # Verify first entry structure + first_stat = result.stats[0] + assert hasattr(first_stat, "country_name") + assert hasattr(first_stat, "player_count") + assert isinstance(first_stat.player_count, int) + assert first_stat.player_count > 0 + + def test_state_players_with_enum(self, client: IfpaClient) -> None: + """Test state_players with StatsRankType enum.""" + # Use enum parameter + result = client.stats.state_players(rank_type=StatsRankType.OPEN) + + # Verify response + assert isinstance(result, StatePlayersResponse) + assert "Players by State" in result.type + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry structure + first_stat = result.stats[0] + assert hasattr(first_stat, "stateprov") + assert hasattr(first_stat, "player_count") + assert isinstance(first_stat.player_count, int) + + def test_lucrative_tournaments_with_enums(self, client: IfpaClient) -> None: + """Test lucrative_tournaments with both StatsRankType and MajorTournament enums.""" + # Use both enum parameters + result = client.stats.lucrative_tournaments( + rank_type=StatsRankType.WOMEN, major=MajorTournament.YES + ) + + # Verify response + assert isinstance(result, LucrativeTournamentsResponse) + assert result.type == "Lucrative Tournaments" + assert result.rank_type == "WOMEN" + assert len(result.stats) > 0 + + # Verify first entry structure + first_stat = result.stats[0] + assert hasattr(first_stat, "tournament_name") + assert hasattr(first_stat, "tournament_value") + assert isinstance(first_stat.tournament_value, float) + assert first_stat.tournament_value > 0 + + def test_overall_with_enum(self, client: IfpaClient) -> None: + """Test overall with SystemCode enum. + + Note: As of 2025-11-19, API bug causes WOMEN to return OPEN data. + This test verifies the enum parameter is accepted. + """ + # Use SystemCode enum + result = client.stats.overall(system_code=SystemCode.OPEN) + + # Verify response + assert isinstance(result, OverallStatsResponse) + assert result.type == "Overall Stats" + assert result.system_code == "OPEN" + + # Verify stats object structure + stats = result.stats + assert hasattr(stats, "overall_player_count") + assert hasattr(stats, "active_player_count") + assert isinstance(stats.overall_player_count, int) + assert isinstance(stats.active_player_count, int) + assert stats.overall_player_count > 0 + + def test_backward_compatibility_mixed_params(self, client: IfpaClient) -> None: + """Test mixing enum and string parameters for backwards compatibility.""" + # Mix enum (rank_type) with string (country_code) + result = client.stats.events_by_year(rank_type=StatsRankType.OPEN, country_code="US") + + # Verify response + assert isinstance(result, EventsByYearResponse) + assert "Events" in result.type and "Year" in result.type + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry structure + first_stat = result.stats[0] + assert hasattr(first_stat, "year") + assert hasattr(first_stat, "tournament_count") + assert isinstance(first_stat.tournament_count, int) + + def test_largest_tournaments_with_enum(self, client: IfpaClient) -> None: + """Test largest_tournaments with StatsRankType enum.""" + # Use enum parameter + result = client.stats.largest_tournaments(rank_type=StatsRankType.OPEN) + + # Verify response + assert isinstance(result, LargestTournamentsResponse) + assert result.type == "Largest Tournaments" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry structure + first_stat = result.stats[0] + assert hasattr(first_stat, "tournament_name") + assert hasattr(first_stat, "player_count") + assert isinstance(first_stat.player_count, int) + assert first_stat.player_count > 0 + + def test_state_tournaments_with_enum(self, client: IfpaClient) -> None: + """Test state_tournaments with StatsRankType enum.""" + # Use enum parameter + result = client.stats.state_tournaments(rank_type=StatsRankType.WOMEN) + + # Verify response + assert isinstance(result, StateTournamentsResponse) + assert "Tournaments by State" in result.type + assert result.rank_type == "WOMEN" + assert len(result.stats) > 0 + + # Verify first entry structure with type checking + first_stat = result.stats[0] + assert_stats_fields_types( + first_stat, + { + "stateprov": str, + "tournament_count": int, + "total_points_all": Decimal, + "total_points_tournament_value": Decimal, + "stats_rank": int, + }, + ) diff --git a/tests/integration/test_tournament_integration.py b/tests/integration/test_tournament_integration.py index a1f4185..b2ad763 100644 --- a/tests/integration/test_tournament_integration.py +++ b/tests/integration/test_tournament_integration.py @@ -13,7 +13,7 @@ import pytest import requests -from ifpa_api import IfpaClient +from ifpa_api import IfpaClient, TournamentSearchType from ifpa_api.core.exceptions import IfpaApiError, TournamentNotLeagueError from ifpa_api.models.tournaments import ( RelatedTournamentsResponse, @@ -229,6 +229,67 @@ def test_search_by_tournament_type(self, api_key: str) -> None: tournament = result.tournaments[0] print(f" Sample: {tournament.tournament_name} (ID: {tournament.tournament_id})") + def test_search_with_enum_women(self, api_key: str) -> None: + """Test search using TournamentSearchType.WOMEN enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + + result = ( + client.tournament.query().tournament_type(TournamentSearchType.WOMEN).limit(10).get() + ) + + assert isinstance(result, TournamentSearchResponse) + assert result.tournaments is not None + print( + f"✓ search(tournament_type=WOMEN enum) returned {len(result.tournaments)} tournaments" + ) + if len(result.tournaments) > 0: + tournament = result.tournaments[0] + print(f" Sample: {tournament.tournament_name} (ID: {tournament.tournament_id})") + + def test_search_with_enum_youth(self, api_key: str) -> None: + """Test search using TournamentSearchType.YOUTH enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + + result = ( + client.tournament.query().tournament_type(TournamentSearchType.YOUTH).limit(10).get() + ) + + assert isinstance(result, TournamentSearchResponse) + assert result.tournaments is not None + print( + f"✓ search(tournament_type=YOUTH enum) returned {len(result.tournaments)} tournaments" + ) + + def test_search_with_enum_league(self, api_key: str) -> None: + """Test search using TournamentSearchType.LEAGUE enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + + result = ( + client.tournament.query().tournament_type(TournamentSearchType.LEAGUE).limit(10).get() + ) + + assert isinstance(result, TournamentSearchResponse) + assert result.tournaments is not None + print( + f"✓ search(tournament_type=LEAGUE enum) returned {len(result.tournaments)} tournaments" + ) + + def test_search_with_enum_open(self, api_key: str) -> None: + """Test search using TournamentSearchType.OPEN enum.""" + skip_if_no_api_key() + client = IfpaClient(api_key=api_key) + + result = ( + client.tournament.query().tournament_type(TournamentSearchType.OPEN).limit(10).get() + ) + + assert isinstance(result, TournamentSearchResponse) + assert result.tournaments is not None + print(f"✓ search(tournament_type=OPEN enum) returned {len(result.tournaments)} tournaments") + def test_search_with_pagination(self, api_key: str, count_small: int) -> None: """Test search with pagination parameters (start_pos, count).""" skip_if_no_api_key() @@ -425,21 +486,37 @@ def test_details_from_helper(self, api_key: str) -> None: assert tournament.tournament_name is not None def test_details_with_invalid_tournament(self, api_key: str) -> None: - """Test details() with an invalid tournament ID raises error.""" + """Test details() with an invalid tournament ID raises error. + + Note: IFPA API behavior is intermittent: + - Sometimes returns 400 error (wrapped as IfpaApiError) + - Sometimes returns 200 with empty dict (causes ValidationError) + This test accepts both scenarios and logs which occurred. + """ skip_if_no_api_key() client = IfpaClient(api_key=api_key) - # Use a very high ID that shouldn't exist - # API returns 200 with empty dict, which causes Pydantic ValidationError from pydantic import ValidationError - with pytest.raises(ValidationError) as exc_info: - client.tournament(99999999).details() + from ifpa_api.core.exceptions import IfpaApiError - print( - f"✓ details() with invalid ID raised ValidationError " - f"(API returned empty data): {exc_info.value}" - ) + # Use a very high ID that shouldn't exist + try: + client.tournament(99999999).details() + pytest.fail("Expected either ValidationError or IfpaApiError, but no error was raised") + except ValidationError: + # Old API behavior: 200 with empty dict + print( + "✓ details() with invalid ID raised ValidationError " + "(API returned 200 with empty data) - Old behavior" + ) + except IfpaApiError as exc: + # New API behavior: 400 with error message + assert exc.status_code == 400 + print( + f"✓ details() with invalid ID raised IfpaApiError " + f"(API returned 400: {exc.message}) - New behavior" + ) def test_details_not_found(self, api_key: str) -> None: """Test that getting non-existent tournament raises appropriate error.""" diff --git a/tests/unit/test_rankings.py b/tests/unit/test_rankings.py index 091353f..ee522b3 100644 --- a/tests/unit/test_rankings.py +++ b/tests/unit/test_rankings.py @@ -6,6 +6,7 @@ import pytest import requests_mock +from ifpa_api import RankingDivision from ifpa_api.client import IfpaClient from ifpa_api.core.exceptions import IfpaApiError from ifpa_api.models.rankings import ( @@ -180,6 +181,54 @@ def test_women_rankings_with_filters(self, mock_requests: requests_mock.Mocker) assert "count=25" in query assert "country=us" in query + def test_women_rankings_with_enum_open(self, mock_requests: requests_mock.Mocker) -> None: + """Test women's rankings using RankingDivision.OPEN enum.""" + mock_requests.get( + "https://api.ifpapinball.com/rankings/women/open", + json={ + "rankings": [ + { + "player_id": 1001, + "current_rank": 1, + "name": "Top Woman Player", + "rating_value": 800.5, + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + rankings = client.rankings.women(tournament_type=RankingDivision.OPEN, count=50) + + assert len(rankings.rankings) == 1 + assert mock_requests.last_request is not None + assert "women/open" in mock_requests.last_request.path + + def test_women_rankings_with_enum_women(self, mock_requests: requests_mock.Mocker) -> None: + """Test women's rankings using RankingDivision.WOMEN enum.""" + mock_requests.get( + "https://api.ifpapinball.com/rankings/women/women", + json={ + "rankings": [ + { + "player_id": 1002, + "current_rank": 1, + "name": "Top Women-Only Player", + "rating_value": 750.0, + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + rankings = client.rankings.women(tournament_type=RankingDivision.WOMEN, count=50) + + assert len(rankings.rankings) == 1 + assert mock_requests.last_request is not None + assert "women/women" in mock_requests.last_request.path + def test_youth_rankings(self, mock_requests: requests_mock.Mocker) -> None: """Test getting youth rankings.""" mock_requests.get( @@ -280,6 +329,54 @@ def test_pro_rankings_women_division(self, mock_requests: requests_mock.Mocker) assert mock_requests.last_request is not None assert "pro/women" in mock_requests.last_request.path + def test_pro_rankings_with_enum_open(self, mock_requests: requests_mock.Mocker) -> None: + """Test pro rankings using RankingDivision.OPEN enum.""" + mock_requests.get( + "https://api.ifpapinball.com/rankings/pro/open", + json={ + "rankings": [ + { + "player_id": 4001, + "current_rank": 1, + "name": "Top Pro Player", + "rating_value": 1200.0, + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + rankings = client.rankings.pro(ranking_system=RankingDivision.OPEN, count=50) + + assert len(rankings.rankings) == 1 + assert mock_requests.last_request is not None + assert "pro/open" in mock_requests.last_request.path + + def test_pro_rankings_with_enum_women(self, mock_requests: requests_mock.Mocker) -> None: + """Test pro rankings using RankingDivision.WOMEN enum.""" + mock_requests.get( + "https://api.ifpapinball.com/rankings/pro/women", + json={ + "rankings": [ + { + "player_id": 4002, + "current_rank": 1, + "name": "Top Women Pro Player", + "rating_value": 1100.0, + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + rankings = client.rankings.pro(ranking_system=RankingDivision.WOMEN, count=50) + + assert len(rankings.rankings) == 1 + assert mock_requests.last_request is not None + assert "pro/women" in mock_requests.last_request.path + class TestRankingsClientCountryAndCustom: """Test cases for country and custom rankings.""" diff --git a/tests/unit/test_stats.py b/tests/unit/test_stats.py new file mode 100644 index 0000000..75414ad --- /dev/null +++ b/tests/unit/test_stats.py @@ -0,0 +1,1764 @@ +"""Unit tests for StatsClient. + +Tests the stats resource client using mocked HTTP requests with real API responses. +""" + +import pytest +import requests_mock + +from ifpa_api.client import IfpaClient +from ifpa_api.core.exceptions import IfpaApiError +from ifpa_api.models.common import MajorTournament, StatsRankType, SystemCode +from ifpa_api.models.stats import ( + CountryPlayersResponse, + EventsAttendedPeriodResponse, + EventsByYearResponse, + LargestTournamentsResponse, + LucrativeTournamentsResponse, + OverallStatsResponse, + PlayersByYearResponse, + PointsGivenPeriodResponse, + StatePlayersResponse, + StateTournamentsResponse, +) + + +class TestStatsClientCountryPlayers: + """Test cases for country_players endpoint.""" + + def test_country_players_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test country_players with default parameters (OPEN).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "47101", + "stats_rank": 1, + }, + { + "country_name": "Canada", + "country_code": "CA", + "player_count": "4473", + "stats_rank": 2, + }, + { + "country_name": "Australia", + "country_code": "AU", + "player_count": "3385", + "stats_rank": 3, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.country_players() + + # Verify response structure + assert isinstance(result, CountryPlayersResponse) + assert result.type == "Players by Country" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry + assert result.stats[0].country_name == "United States" + assert result.stats[0].country_code == "US" + assert result.stats[0].player_count == 47101 + assert result.stats[0].stats_rank == 1 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_country_players_with_rank_type(self, mock_requests: requests_mock.Mocker) -> None: + """Test country_players with WOMEN rank_type.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + "rank_type": "WOMEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "7173", + "stats_rank": 1, + }, + { + "country_name": "Canada", + "country_code": "CA", + "player_count": "862", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.country_players(rank_type="WOMEN") + + # Verify rank_type parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "rank_type=" in mock_requests.last_request.query + + # Verify response + assert result.rank_type == "WOMEN" + assert len(result.stats) > 0 + + +class TestStatsClientStatePlayers: + """Test cases for state_players endpoint.""" + + def test_state_players_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test state_players with default parameters (OPEN).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/state_players", + json={ + "type": "Players by State (North America)", + "rank_type": "OPEN", + "stats": [ + {"stateprov": "Unknown", "player_count": "38167", "stats_rank": 1}, + {"stateprov": "CA", "player_count": "662", "stats_rank": 2}, + {"stateprov": "WA", "player_count": "549", "stats_rank": 3}, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.state_players() + + # Verify response structure + assert isinstance(result, StatePlayersResponse) + assert result.type == "Players by State (North America)" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry + first_state = result.stats[0] + assert first_state.stateprov is not None + assert first_state.player_count > 0 + assert first_state.stats_rank >= 1 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_state_players_with_rank_type(self, mock_requests: requests_mock.Mocker) -> None: + """Test state_players with WOMEN rank_type.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/state_players", + json={ + "type": "Players by State (North America)", + "rank_type": "WOMEN", + "stats": [ + {"stateprov": "Unknown", "player_count": "5182", "stats_rank": 1}, + {"stateprov": "CA", "player_count": "131", "stats_rank": 2}, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.state_players(rank_type="WOMEN") + + # Verify rank_type parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "rank_type=" in mock_requests.last_request.query + + # Verify response + assert result.rank_type == "WOMEN" + assert len(result.stats) > 0 + + +class TestStatsClientStateTournaments: + """Test cases for state_tournaments endpoint.""" + + def test_state_tournaments_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test state_tournaments with default parameters (OPEN).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/state_tournaments", + json={ + "type": "Tournaments by State (North America)", + "rank_type": "OPEN", + "stats": [ + { + "stateprov": "WA", + "tournament_count": "5729", + "total_points_all": "232841.4800", + "total_points_tournament_value": "39232.8200", + "stats_rank": 1, + }, + { + "stateprov": "MI", + "tournament_count": "3469", + "total_points_all": "122382.2200", + "total_points_tournament_value": "29354.8200", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.state_tournaments() + + # Verify response structure + assert isinstance(result, StateTournamentsResponse) + assert result.type == "Tournaments by State (North America)" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry has required fields + first_state = result.stats[0] + assert first_state.stateprov is not None + assert first_state.tournament_count > 0 + assert first_state.total_points_all > 0 + assert first_state.total_points_tournament_value > 0 + assert first_state.stats_rank >= 1 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_state_tournaments_with_rank_type(self, mock_requests: requests_mock.Mocker) -> None: + """Test state_tournaments with WOMEN rank_type.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/state_tournaments", + json={ + "type": "Tournaments by State (North America)", + "rank_type": "WOMEN", + "stats": [ + { + "stateprov": "TX", + "tournament_count": "458", + "total_points_all": "21036.1100", + "total_points_tournament_value": "5084.3200", + "stats_rank": 1, + }, + { + "stateprov": "MI", + "tournament_count": "349", + "total_points_all": "2424.3000", + "total_points_tournament_value": "1104.8000", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.state_tournaments(rank_type="WOMEN") + + # Verify rank_type parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "rank_type=" in mock_requests.last_request.query + + # Verify response + assert result.rank_type == "WOMEN" + assert len(result.stats) > 0 + + +class TestStatsClientEventsByYear: + """Test cases for events_by_year endpoint.""" + + def test_events_by_year_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test events_by_year with default parameters (OPEN).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/events_by_year", + json={ + "type": "Events Per Year", + "rank_type": "OPEN", + "stats": [ + { + "year": "2025", + "country_count": "30", + "tournament_count": "12300", + "player_count": "277684", + "stats_rank": 1, + }, + { + "year": "2024", + "country_count": "25", + "tournament_count": "12776", + "player_count": "291118", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.events_by_year() + + # Verify response structure + assert isinstance(result, EventsByYearResponse) + assert result.type == "Events Per Year" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry + first_year = result.stats[0] + assert first_year.year is not None + assert first_year.country_count > 0 + assert first_year.tournament_count > 0 + assert first_year.player_count > 0 + assert first_year.stats_rank >= 1 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_events_by_year_with_rank_type(self, mock_requests: requests_mock.Mocker) -> None: + """Test events_by_year with WOMEN rank_type.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/events_by_year", + json={ + "type": "Events Per Year", + "rank_type": "WOMEN", + "stats": [ + { + "year": "2025", + "country_count": "15", + "tournament_count": "1686", + "player_count": "22992", + "stats_rank": 1, + }, + { + "year": "2024", + "country_count": "10", + "tournament_count": "1597", + "player_count": "20927", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.events_by_year(rank_type="WOMEN") + + # Verify rank_type parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "rank_type=" in mock_requests.last_request.query + + # Verify response + assert result.rank_type == "WOMEN" + + def test_events_by_year_with_country_code(self, mock_requests: requests_mock.Mocker) -> None: + """Test events_by_year with country_code filter.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/events_by_year", + json={ + "type": "Events Per Year", + "rank_type": "OPEN", + "stats": [ + { + "year": "2025", + "country_count": "1", + "tournament_count": "9680", + "player_count": "209880", + "stats_rank": 1, + }, + { + "year": "2024", + "country_count": "1", + "tournament_count": "10042", + "player_count": "221107", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.events_by_year(country_code="US") + + # Verify country_code parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "country_code=" in mock_requests.last_request.query + + # Verify response + assert isinstance(result, EventsByYearResponse) + assert len(result.stats) > 0 + + +class TestStatsClientPlayersByYear: + """Test cases for players_by_year endpoint.""" + + def test_players_by_year(self, mock_requests: requests_mock.Mocker) -> None: + """Test players_by_year with no parameters.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/players_by_year", + json={ + "type": "Players by Year", + "rank_type": "OPEN", + "stats": [ + { + "year": "2025", + "current_year_count": "39169", + "previous_year_count": "18453", + "previous_2_year_count": "8278", + "stats_rank": 1, + }, + { + "year": "2024", + "current_year_count": "38914", + "previous_year_count": "14683", + "previous_2_year_count": "6707", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.players_by_year() + + # Verify response structure + assert isinstance(result, PlayersByYearResponse) + assert result.type == "Players by Year" + assert len(result.stats) > 0 + + # Verify first entry + first_year = result.stats[0] + assert first_year.year is not None + assert first_year.current_year_count > 0 + assert first_year.previous_year_count >= 0 + assert first_year.previous_2_year_count >= 0 + assert first_year.stats_rank >= 1 + + # Verify no params sent (no parameters for this endpoint) + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + +class TestStatsClientLargestTournaments: + """Test cases for largest_tournaments endpoint.""" + + def test_largest_tournaments_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test largest_tournaments with default parameters (OPEN).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/largest_tournaments", + json={ + "type": "Largest Tournaments", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "987", + "tournament_id": "34625", + "tournament_name": "Pinburgh Match-Play Championship", + "event_name": "Main Tournament", + "tournament_date": "2019-08-03", + "stats_rank": 1, + }, + { + "country_name": "United States", + "country_code": "US", + "player_count": "822", + "tournament_id": "26092", + "tournament_name": "Pinburgh Match-Play Championship", + "event_name": "Main Tournament", + "tournament_date": "2018-07-28", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.largest_tournaments() + + # Verify response structure + assert isinstance(result, LargestTournamentsResponse) + assert result.type == "Largest Tournaments" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry + first_tourney = result.stats[0] + assert first_tourney.country_name is not None + assert first_tourney.country_code is not None + assert first_tourney.player_count > 0 + assert first_tourney.tournament_id > 0 + assert first_tourney.tournament_name is not None + assert first_tourney.stats_rank >= 1 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_largest_tournaments_with_rank_type(self, mock_requests: requests_mock.Mocker) -> None: + """Test largest_tournaments with WOMEN rank_type.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/largest_tournaments", + json={ + "type": "Largest Tournaments", + "rank_type": "WOMEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "127", + "tournament_id": "34627", + "tournament_name": "Women's International Pinball Tournament", + "event_name": "Main Tournament", + "tournament_date": "2019-08-04", + "stats_rank": 1, + }, + { + "country_name": "United States", + "country_code": "US", + "player_count": "86", + "tournament_id": "103188", + "tournament_name": "Expo flipOUT! Womens Big Bracket", + "event_name": "Womens Division", + "tournament_date": "2025-10-18", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.largest_tournaments(rank_type="WOMEN") + + # Verify rank_type parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "rank_type=" in mock_requests.last_request.query + + # Verify response + assert result.rank_type == "WOMEN" + + def test_largest_tournaments_with_country_code( + self, mock_requests: requests_mock.Mocker + ) -> None: + """Test largest_tournaments with country_code filter.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/largest_tournaments", + json={ + "type": "Largest Tournaments", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "987", + "tournament_id": "34625", + "tournament_name": "Pinburgh Match-Play Championship", + "event_name": "Main Tournament", + "tournament_date": "2019-08-03", + "stats_rank": 1, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.largest_tournaments(country_code="US") + + # Verify country_code parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "country_code=" in mock_requests.last_request.query + + # Verify response + assert isinstance(result, LargestTournamentsResponse) + + +class TestStatsClientLucrativeTournaments: + """Test cases for lucrative_tournaments endpoint.""" + + def test_lucrative_tournaments_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test lucrative_tournaments with default parameters (major=Y).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/lucrative_tournaments", + json={ + "type": "Lucrative Tournaments", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "tournament_id": "83318", + "tournament_name": "The Open - IFPA World Championship", + "event_name": "Main Tournament", + "tournament_date": "2025-01-26", + "tournament_value": 400.79, + "stats_rank": 1, + }, + { + "country_name": "United States", + "country_code": "US", + "tournament_id": "78171", + "tournament_name": "IFPA World Pinball Championship", + "event_name": "Main Tournament", + "tournament_date": "2024-06-09", + "tournament_value": 393.28, + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.lucrative_tournaments() + + # Verify response structure + assert isinstance(result, LucrativeTournamentsResponse) + assert result.type == "Lucrative Tournaments" + assert result.rank_type == "OPEN" + assert len(result.stats) > 0 + + # Verify first entry + first_tourney = result.stats[0] + assert first_tourney.country_name is not None + assert first_tourney.country_code is not None + assert first_tourney.tournament_id > 0 + assert first_tourney.tournament_name is not None + assert first_tourney.tournament_value > 0 + assert first_tourney.stats_rank >= 1 + + # Verify no params sent for default (major=Y is default) + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_lucrative_tournaments_non_major(self, mock_requests: requests_mock.Mocker) -> None: + """Test lucrative_tournaments with major=N.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/lucrative_tournaments", + json={ + "type": "Lucrative Tournaments", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "tournament_id": "83321", + "tournament_name": "It Never Drains in Southern California", + "event_name": "Classics", + "tournament_date": "2025-01-25", + "tournament_value": 281.01, + "stats_rank": 1, + }, + { + "country_name": "United States", + "country_code": "US", + "tournament_id": "66353", + "tournament_name": "It Never Drains in Southern California", + "event_name": "Classics", + "tournament_date": "2024-01-06", + "tournament_value": 266.56, + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.lucrative_tournaments(major="N") + + # Verify major parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "major=" in mock_requests.last_request.query + + # Verify response + assert isinstance(result, LucrativeTournamentsResponse) + + def test_lucrative_tournaments_with_country_code( + self, mock_requests: requests_mock.Mocker + ) -> None: + """Test lucrative_tournaments with country_code filter.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/lucrative_tournaments", + json={ + "type": "Lucrative Tournaments", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "tournament_id": "83318", + "tournament_name": "The Open - IFPA World Championship", + "event_name": "Main Tournament", + "tournament_date": "2025-01-26", + "tournament_value": 400.79, + "stats_rank": 1, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.lucrative_tournaments(country_code="US") + + # Verify country_code parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "country_code=" in mock_requests.last_request.query + + # Verify response + assert isinstance(result, LucrativeTournamentsResponse) + + +class TestStatsClientPointsGivenPeriod: + """Test cases for points_given_period endpoint.""" + + def test_points_given_period_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test points_given_period with default parameters.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/points_given_period", + json={ + "type": "Points given Period", + "start_date": "'2024-11-19'", + "end_date": "2025-11-19", + "return_count": 25, + "rank_type": "OPEN", + "stats": [ + { + "player_id": "49549", + "first_name": "Arvid", + "last_name": "Flygare", + "country_name": "Sweden", + "country_code": "SE", + "wppr_points": "4033.46", + "stats_rank": 1, + }, + { + "player_id": "16004", + "first_name": "Viggo", + "last_name": "Löwgren", + "country_name": "Sweden", + "country_code": "SE", + "wppr_points": "3854.59", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.points_given_period() + + # Verify response structure + assert isinstance(result, PointsGivenPeriodResponse) + assert result.type == "Points given Period" + assert result.rank_type == "OPEN" + assert result.start_date is not None + assert result.end_date is not None + assert result.return_count > 0 + assert len(result.stats) > 0 + + # Verify first entry + first_player = result.stats[0] + assert first_player.player_id > 0 + assert first_player.first_name is not None + assert first_player.last_name is not None + assert first_player.country_name is not None + assert first_player.wppr_points > 0 + assert first_player.stats_rank >= 1 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_points_given_period_with_date_range(self, mock_requests: requests_mock.Mocker) -> None: + """Test points_given_period with start_date and end_date.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/points_given_period", + json={ + "type": "Points given Period", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "return_count": 25, + "rank_type": "OPEN", + "stats": [ + { + "player_id": "1605", + "first_name": "Escher", + "last_name": "Lefkoff", + "country_name": "Australia", + "country_code": "AU", + "wppr_points": "4264.61", + "stats_rank": 1, + }, + { + "player_id": "16004", + "first_name": "Viggo", + "last_name": "Löwgren", + "country_name": "Sweden", + "country_code": "SE", + "wppr_points": "3049.80", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.points_given_period(start_date="2024-01-01", end_date="2024-12-31") + + # Verify date parameters were passed + assert mock_requests.called + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "start_date=" in query + assert "end_date=" in query + + # Verify response + assert isinstance(result, PointsGivenPeriodResponse) + + def test_points_given_period_with_limit(self, mock_requests: requests_mock.Mocker) -> None: + """Test points_given_period with limit parameter.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/points_given_period", + json={ + "type": "Points given Period", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "return_count": 25, + "rank_type": "OPEN", + "stats": [ + { + "player_id": "1605", + "first_name": "Escher", + "last_name": "Lefkoff", + "country_name": "Australia", + "country_code": "AU", + "wppr_points": "4264.61", + "stats_rank": 1, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.points_given_period( + start_date="2024-01-01", end_date="2024-12-31", limit=10 + ) + + # Verify all parameters were passed + assert mock_requests.called + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "start_date=" in query + assert "end_date=" in query + assert "limit=" in query + + # Verify response + assert isinstance(result, PointsGivenPeriodResponse) + # Note: API returns 25 results regardless of limit in this fixture + assert result.return_count == 25 + + def test_points_given_period_all_params(self, mock_requests: requests_mock.Mocker) -> None: + """Test points_given_period with all parameters except rank_type. + + Note: rank_type="OPEN" is the default so it's not sent as a parameter. + """ + mock_requests.get( + "https://api.ifpapinball.com/stats/points_given_period", + json={ + "type": "Points given Period", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "return_count": 25, + "rank_type": "OPEN", + "stats": [ + { + "player_id": "40612", + "first_name": "Carlos", + "last_name": "Delaserda", + "country_name": "United States", + "country_code": "US", + "wppr_points": "2986.50", + "stats_rank": 1, + }, + { + "player_id": "8202", + "first_name": "Zach", + "last_name": "McCarthy", + "country_name": "United States", + "country_code": "US", + "wppr_points": "2940.96", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.points_given_period( + country_code="US", + start_date="2024-01-01", + end_date="2024-12-31", + limit=25, + ) + + # Verify all parameters were passed (except rank_type which defaults to OPEN) + assert mock_requests.called + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "country_code=" in query + assert "start_date=" in query + assert "end_date=" in query + assert "limit=" in query + + # Verify response + assert isinstance(result, PointsGivenPeriodResponse) + + +class TestStatsClientEventsAttendedPeriod: + """Test cases for events_attended_period endpoint.""" + + def test_events_attended_period_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test events_attended_period with default parameters.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/events_attended_period", + json={ + "type": "Events attended over a period of time", + "start_date": "'2024-11-19'", + "end_date": "2025-11-19", + "return_count": 25, + "stats": [ + { + "player_id": "91929", + "first_name": "Nick", + "last_name": "Elliott", + "country_name": "United States", + "country_code": "US", + "tournament_count": "202", + "stats_rank": 1, + }, + { + "player_id": "55991", + "first_name": "Dawnda", + "last_name": "Durbin", + "country_name": "United States", + "country_code": "US", + "tournament_count": "200", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.events_attended_period() + + # Verify response structure + assert isinstance(result, EventsAttendedPeriodResponse) + assert result.type == "Events attended over a period of time" + assert result.start_date is not None + assert result.end_date is not None + assert result.return_count > 0 + assert len(result.stats) > 0 + + # Verify first entry + first_player = result.stats[0] + assert first_player.player_id > 0 + assert first_player.first_name is not None + assert first_player.last_name is not None + assert first_player.country_name is not None + assert first_player.tournament_count > 0 + assert first_player.stats_rank >= 1 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_events_attended_period_with_date_range( + self, mock_requests: requests_mock.Mocker + ) -> None: + """Test events_attended_period with start_date and end_date.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/events_attended_period", + json={ + "type": "Events attended over a period of time", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "return_count": 25, + "stats": [ + { + "player_id": "89391", + "first_name": "Ben", + "last_name": "Fodor", + "country_name": "United States", + "country_code": "US", + "tournament_count": "199", + "stats_rank": 1, + }, + { + "player_id": "55991", + "first_name": "Dawnda", + "last_name": "Durbin", + "country_name": "United States", + "country_code": "US", + "tournament_count": "188", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.events_attended_period(start_date="2024-01-01", end_date="2024-12-31") + + # Verify date parameters were passed + assert mock_requests.called + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "start_date=" in query + assert "end_date=" in query + + # Verify response + assert isinstance(result, EventsAttendedPeriodResponse) + + def test_events_attended_period_with_limit(self, mock_requests: requests_mock.Mocker) -> None: + """Test events_attended_period with limit parameter.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/events_attended_period", + json={ + "type": "Events attended over a period of time", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "return_count": 25, + "stats": [ + { + "player_id": "89391", + "first_name": "Ben", + "last_name": "Fodor", + "country_name": "United States", + "country_code": "US", + "tournament_count": "199", + "stats_rank": 1, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.events_attended_period( + start_date="2024-01-01", end_date="2024-12-31", limit=10 + ) + + # Verify all parameters were passed + assert mock_requests.called + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "start_date=" in query + assert "end_date=" in query + assert "limit=" in query + + # Verify response + assert isinstance(result, EventsAttendedPeriodResponse) + # Note: API returns 25 results regardless of limit in this fixture + assert result.return_count == 25 + + def test_events_attended_period_all_params(self, mock_requests: requests_mock.Mocker) -> None: + """Test events_attended_period with all parameters except rank_type. + + Note: rank_type="OPEN" is the default so it's not sent as a parameter. + """ + mock_requests.get( + "https://api.ifpapinball.com/stats/events_attended_period", + json={ + "type": "Events attended over a period of time", + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "return_count": 25, + "stats": [ + { + "player_id": "89391", + "first_name": "Ben", + "last_name": "Fodor", + "country_name": "United States", + "country_code": "US", + "tournament_count": "199", + "stats_rank": 1, + }, + { + "player_id": "55991", + "first_name": "Dawnda", + "last_name": "Durbin", + "country_name": "United States", + "country_code": "US", + "tournament_count": "188", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.events_attended_period( + country_code="US", + start_date="2024-01-01", + end_date="2024-12-31", + limit=25, + ) + + # Verify all parameters were passed (except rank_type which defaults to OPEN) + assert mock_requests.called + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "country_code=" in query + assert "start_date=" in query + assert "end_date=" in query + assert "limit=" in query + + # Verify response + assert isinstance(result, EventsAttendedPeriodResponse) + + +class TestStatsClientOverall: + """Test cases for overall endpoint.""" + + def test_overall_default(self, mock_requests: requests_mock.Mocker) -> None: + """Test overall with default system_code (OPEN).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/overall", + json={ + "type": "Overall Stats", + "system_code": "OPEN", + "stats": { + "overall_player_count": 143756, + "active_player_count": 71907, + "tournament_count": 85392, + "tournament_count_last_month": 1202, + "tournament_count_this_year": 14088, + "tournament_player_count": 1956522, + "tournament_player_count_average": 22.9, + "age": { + "age_under_18": 3.47, + "age_18_to_29": 9.4, + "age_30_to_39": 22.7, + "age_40_to_49": 31.07, + "age_50_to_99": 33.36, + }, + }, + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.overall() + + # Verify response structure + assert isinstance(result, OverallStatsResponse) + assert result.type == "Overall Stats" + assert result.system_code == "OPEN" + + # Verify stats object (note: single object, not array) + stats = result.stats + assert stats.overall_player_count > 0 + assert stats.active_player_count > 0 + assert stats.tournament_count > 0 + assert stats.tournament_count_last_month >= 0 + assert stats.tournament_count_this_year >= 0 + assert stats.tournament_player_count > 0 + assert stats.tournament_player_count_average > 0 + + # Verify age distribution + assert stats.age.age_under_18 >= 0 + assert stats.age.age_18_to_29 >= 0 + assert stats.age.age_30_to_39 >= 0 + assert stats.age.age_40_to_49 >= 0 + assert stats.age.age_50_to_99 >= 0 + + # Verify no params sent for default + assert mock_requests.called + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_overall_with_system_code(self, mock_requests: requests_mock.Mocker) -> None: + """Test overall with system_code=WOMEN. + + Note: As of 2025-11-19, this is a known API bug where WOMEN returns OPEN data. + We test that the parameter is correctly passed to the API. + """ + mock_requests.get( + "https://api.ifpapinball.com/stats/overall", + json={ + "type": "Overall Stats", + "system_code": "OPEN", + "stats": { + "overall_player_count": 143756, + "active_player_count": 71907, + "tournament_count": 85392, + "tournament_count_last_month": 1202, + "tournament_count_this_year": 14088, + "tournament_player_count": 1956522, + "tournament_player_count_average": 22.9, + "age": { + "age_under_18": 3.47, + "age_18_to_29": 9.4, + "age_30_to_39": 22.7, + "age_40_to_49": 31.07, + "age_50_to_99": 33.36, + }, + }, + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.overall(system_code="WOMEN") + + # Verify system_code parameter was passed + assert mock_requests.called + assert mock_requests.last_request is not None + assert "system_code=" in mock_requests.last_request.query + + # Verify response + assert isinstance(result, OverallStatsResponse) + + +class TestStatsClientErrors: + """Test error handling for stats client.""" + + def test_stats_handles_api_error(self, mock_requests: requests_mock.Mocker) -> None: + """Test that stats properly handles API errors.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + status_code=503, + json={"error": "Service temporarily unavailable"}, + ) + + client = IfpaClient(api_key="test-key") + with pytest.raises(IfpaApiError) as exc_info: + client.stats.country_players() + + assert exc_info.value.status_code == 503 + + def test_stats_handles_404(self, mock_requests: requests_mock.Mocker) -> None: + """Test that stats handles not found errors.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/overall", + status_code=404, + json={"error": "Endpoint not found"}, + ) + + client = IfpaClient(api_key="test-key") + with pytest.raises(IfpaApiError) as exc_info: + client.stats.overall() + + assert exc_info.value.status_code == 404 + + +class TestStatsClientFieldCoercion: + """Test that field validators properly coerce string values to correct types.""" + + def test_country_players_coerces_player_count( + self, mock_requests: requests_mock.Mocker + ) -> None: + """Test that player_count is coerced from string to int.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "47101", + "stats_rank": 1, + } + ], + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.country_players() + + # Verify that player_count (returned as string "47101") is coerced to int + assert isinstance(result.stats[0].player_count, int) + assert result.stats[0].player_count == 47101 + + def test_overall_returns_proper_numeric_types( + self, mock_requests: requests_mock.Mocker + ) -> None: + """Test that overall endpoint returns proper numeric types (not strings). + + Unlike other stats endpoints, overall returns proper int/float types. + """ + mock_requests.get( + "https://api.ifpapinball.com/stats/overall", + json={ + "type": "Overall Stats", + "system_code": "OPEN", + "stats": { + "overall_player_count": 143756, + "active_player_count": 71907, + "tournament_count": 85392, + "tournament_count_last_month": 1202, + "tournament_count_this_year": 14088, + "tournament_player_count": 1956522, + "tournament_player_count_average": 22.9, + "age": { + "age_under_18": 3.47, + "age_18_to_29": 9.4, + "age_30_to_39": 22.7, + "age_40_to_49": 31.07, + "age_50_to_99": 33.36, + }, + }, + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.overall() + + # Verify proper types + assert isinstance(result.stats.overall_player_count, int) + assert isinstance(result.stats.tournament_player_count_average, float) + assert isinstance(result.stats.age.age_under_18, float) + + +class TestStatsClientEdgeCases: + """Test edge cases and error handling for stats client.""" + + def test_country_players_empty_response(self, mock_requests: requests_mock.Mocker) -> None: + """Test country_players handles empty stats array.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={"type": "Players by Country", "rank_type": "OPEN", "stats": []}, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.country_players() + + assert isinstance(result, CountryPlayersResponse) + assert len(result.stats) == 0 + assert result.type == "Players by Country" + + def test_country_players_missing_required_field( + self, mock_requests: requests_mock.Mocker + ) -> None: + """Test country_players raises validation error for missing required fields.""" + from pydantic import ValidationError + + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + "rank_type": "OPEN", + "stats": [{"country_name": "US"}], # Missing player_count, stats_rank + }, + ) + + client = IfpaClient(api_key="test-key") + with pytest.raises(ValidationError): + client.stats.country_players() + + def test_points_given_period_invalid_date_format( + self, mock_requests: requests_mock.Mocker + ) -> None: + """Test points_given_period with invalid date passes to API for validation.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/points_given_period", + status_code=400, + json={"error": "Invalid date format"}, + ) + + client = IfpaClient(api_key="test-key") + with pytest.raises(IfpaApiError) as exc_info: + client.stats.points_given_period(start_date="not-a-date", end_date="also-not-a-date") + + assert exc_info.value.status_code == 400 + + def test_points_given_period_empty_results(self, mock_requests: requests_mock.Mocker) -> None: + """Test points_given_period handles empty stats array gracefully.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/points_given_period", + json={ + "type": "Points Given in Period", + "rank_type": "OPEN", + "start_date": "1900-01-01", + "end_date": "1900-01-31", + "return_count": 0, + "stats": [], # Empty array - no data in this period + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.stats.points_given_period(start_date="1900-01-01", end_date="1900-01-31") + + assert isinstance(result, PointsGivenPeriodResponse) + assert result.return_count == 0 + assert len(result.stats) == 0 + assert result.stats == [] + + def test_country_players_malformed_response(self, mock_requests: requests_mock.Mocker) -> None: + """Test handling of API response missing required fields.""" + from pydantic import ValidationError + + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + # Missing rank_type field + "stats": [ + { + "country_name": "United States", + # Missing player_count field + "tournament_count": 1234, + } + ], + }, + ) + + client = IfpaClient(api_key="test-key") + + # Should raise validation error + with pytest.raises(ValidationError) as exc_info: + client.stats.country_players() + + # Verify error mentions the missing field + error_str = str(exc_info.value).lower() + assert "player_count" in error_str and ( + "field required" in error_str or "missing" in error_str + ) + + def test_period_endpoint_invalid_date_format(self, mock_requests: requests_mock.Mocker) -> None: + """Test period endpoint with incorrectly formatted date.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/points_given_period", + status_code=400, + json={"error": "Invalid date format"}, + ) + + client = IfpaClient(api_key="test-key") + + # Try with US-style date instead of ISO 8601 + with pytest.raises(IfpaApiError) as exc_info: + client.stats.points_given_period( + start_date="01/01/2024", end_date="12/31/2024" # Wrong format + ) + + assert exc_info.value.status_code == 400 + + +class TestStatsClientEnumSupport: + """Test enum parameter support across stats endpoints.""" + + def test_country_players_with_enum(self, mock_requests: requests_mock.Mocker) -> None: + """Test country_players accepts StatsRankType enum.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + "rank_type": "WOMEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "7173", + "stats_rank": 1, + }, + { + "country_name": "Canada", + "country_code": "CA", + "player_count": "862", + "stats_rank": 2, + }, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + # Use enum instead of string + result = client.stats.country_players(rank_type=StatsRankType.WOMEN) + + # Verify it works + assert isinstance(result, CountryPlayersResponse) + assert result.rank_type == "WOMEN" + assert len(result.stats) > 0 + + # Verify correct parameter was sent (requests_mock lowercases query params) + assert mock_requests.last_request is not None + assert "rank_type=women" in mock_requests.last_request.query + + def test_state_players_with_enum(self, mock_requests: requests_mock.Mocker) -> None: + """Test state_players accepts StatsRankType enum.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/state_players", + json={ + "type": "Players by State (North America)", + "rank_type": "OPEN", + "stats": [ + {"stateprov": "Unknown", "player_count": "38167", "stats_rank": 1}, + {"stateprov": "CA", "player_count": "662", "stats_rank": 2}, + ], + }, + ) + + client = IfpaClient(api_key="test-key") + # Use enum for OPEN + result = client.stats.state_players(rank_type=StatsRankType.OPEN) + + # Verify it works + assert isinstance(result, StatePlayersResponse) + assert result.rank_type == "OPEN" + + # Verify no parameter sent for default OPEN + assert mock_requests.last_request is not None + assert mock_requests.last_request.qs == {} + + def test_state_tournaments_with_enum(self, mock_requests: requests_mock.Mocker) -> None: + """Test state_tournaments accepts StatsRankType enum.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/state_tournaments", + json={ + "type": "Tournaments by State (North America)", + "rank_type": "WOMEN", + "stats": [ + { + "stateprov": "TX", + "tournament_count": "458", + "total_points_all": "21036.1100", + "total_points_tournament_value": "5084.3200", + "stats_rank": 1, + } + ], + }, + ) + + client = IfpaClient(api_key="test-key") + # Use enum for WOMEN + result = client.stats.state_tournaments(rank_type=StatsRankType.WOMEN) + + # Verify it works + assert isinstance(result, StateTournamentsResponse) + assert result.rank_type == "WOMEN" + + # Verify correct parameter was sent (requests_mock lowercases query params) + assert mock_requests.last_request is not None + assert "rank_type=women" in mock_requests.last_request.query + + def test_lucrative_tournaments_with_enums(self, mock_requests: requests_mock.Mocker) -> None: + """Test lucrative_tournaments accepts both StatsRankType and MajorTournament enums.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/lucrative_tournaments", + json={ + "type": "Lucrative Tournaments", + "rank_type": "WOMEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "tournament_id": "83321", + "tournament_name": "Women's Championship", + "event_name": "Main Tournament", + "tournament_date": "2025-01-25", + "tournament_value": 281.01, + "stats_rank": 1, + } + ], + }, + ) + + client = IfpaClient(api_key="test-key") + # Use both enums + result = client.stats.lucrative_tournaments( + rank_type=StatsRankType.WOMEN, major=MajorTournament.NO + ) + + # Verify it works + assert isinstance(result, LucrativeTournamentsResponse) + assert result.rank_type == "WOMEN" + + # Verify both parameters were sent correctly (requests_mock lowercases query params) + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "rank_type=women" in query + assert "major=n" in query + + def test_overall_with_enum(self, mock_requests: requests_mock.Mocker) -> None: + """Test overall accepts SystemCode enum.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/overall", + json={ + "type": "Overall Stats", + "system_code": "WOMEN", + "stats": { + "overall_player_count": 20000, + "active_player_count": 10000, + "tournament_count": 5000, + "tournament_count_last_month": 50, + "tournament_count_this_year": 600, + "tournament_player_count": 150000, + "tournament_player_count_average": 18.5, + "age": { + "age_under_18": 4.2, + "age_18_to_29": 12.1, + "age_30_to_39": 25.3, + "age_40_to_49": 28.4, + "age_50_to_99": 30.0, + }, + }, + }, + ) + + client = IfpaClient(api_key="test-key") + # Use SystemCode enum + result = client.stats.overall(system_code=SystemCode.WOMEN) + + # Verify it works + assert isinstance(result, OverallStatsResponse) + assert result.system_code == "WOMEN" + + # Verify correct parameter was sent (requests_mock lowercases query params) + assert mock_requests.last_request is not None + assert "system_code=women" in mock_requests.last_request.query + + def test_backward_compatibility_with_strings(self, mock_requests: requests_mock.Mocker) -> None: + """Test that string parameters still work (backwards compatibility).""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + "rank_type": "OPEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "47101", + "stats_rank": 1, + } + ], + }, + ) + + client = IfpaClient(api_key="test-key") + # Still use string (backwards compatible) + result = client.stats.country_players(rank_type="OPEN") + + # Should work exactly as before + assert isinstance(result, CountryPlayersResponse) + assert result.rank_type == "OPEN" + + def test_enum_value_extraction(self, mock_requests: requests_mock.Mocker) -> None: + """Test that enum .value property is extracted correctly.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json={ + "type": "Players by Country", + "rank_type": "WOMEN", + "stats": [ + { + "country_name": "United States", + "country_code": "US", + "player_count": "7173", + "stats_rank": 1, + } + ], + }, + ) + + client = IfpaClient(api_key="test-key") + + # Use enum and verify .value is extracted + enum_param = StatsRankType.WOMEN + assert enum_param.value == "WOMEN" + + result = client.stats.country_players(rank_type=enum_param) + + # Verify request used the value, not the enum object (requests_mock lowercases query params) + assert mock_requests.last_request is not None + assert "rank_type=women" in mock_requests.last_request.query + assert isinstance(result, CountryPlayersResponse) + + def test_mixed_enum_and_string(self, mock_requests: requests_mock.Mocker) -> None: + """Test passing enum for rank_type and string for country_code.""" + mock_requests.get( + "https://api.ifpapinball.com/stats/events_by_year", + json={ + "type": "Events Per Year", + "rank_type": "WOMEN", + "stats": [ + { + "year": "2025", + "country_count": "1", + "tournament_count": "1686", + "player_count": "22992", + "stats_rank": 1, + } + ], + }, + ) + + client = IfpaClient(api_key="test-key") + # Mix enum and string parameters + result = client.stats.events_by_year(rank_type=StatsRankType.WOMEN, country_code="US") + + # Verify both parameters work correctly + assert isinstance(result, EventsByYearResponse) + assert result.rank_type == "WOMEN" + + # Verify parameters were sent (requests_mock lowercases query params) + assert mock_requests.last_request is not None + query = mock_requests.last_request.query + assert "rank_type=women" in query + assert "country_code=us" in query + + def test_stats_enum_string_equivalence(self, mock_requests: requests_mock.Mocker) -> None: + """Test that enum and string parameters produce identical API calls.""" + # Mock response (same for both calls) + mock_response = { + "type": "Players by Country", + "rank_type": "WOMEN", + "stats": [ + { + "country_code": "US", + "country_name": "United States", + "player_count": 5000, + "stats_rank": 1, + } + ], + } + + # Register mock for both calls + mock_requests.get( + "https://api.ifpapinball.com/stats/country_players", + json=mock_response, + ) + + client = IfpaClient(api_key="test-key") + + # Call 1: Using enum + result_enum = client.stats.country_players(rank_type=StatsRankType.WOMEN) + + # Call 2: Using string + result_string = client.stats.country_players(rank_type="WOMEN") + + # Both should produce identical results + assert result_enum.rank_type == result_string.rank_type + assert result_enum.rank_type == "WOMEN" + assert len(result_enum.stats) == len(result_string.stats) + + # Verify query parameters were identical + history = mock_requests.request_history + assert len(history) == 2 + # Both requests should have sent rank_type=women (lowercase by requests-mock) + assert "rank_type=women" in history[0].query.lower() + assert "rank_type=women" in history[1].query.lower() diff --git a/tests/unit/test_tournaments.py b/tests/unit/test_tournaments.py index 021c9a8..f7d867d 100644 --- a/tests/unit/test_tournaments.py +++ b/tests/unit/test_tournaments.py @@ -6,6 +6,7 @@ import pytest import requests_mock +from ifpa_api import TournamentSearchType from ifpa_api.client import IfpaClient from ifpa_api.core.exceptions import ( IfpaApiError, @@ -132,6 +133,98 @@ def test_search_with_tournament_type(self, mock_requests: requests_mock.Mocker) assert mock_requests.last_request is not None assert "tournament_type=women" in mock_requests.last_request.query + def test_search_with_enum_women(self, mock_requests: requests_mock.Mocker) -> None: + """Test tournament search using TournamentSearchType.WOMEN enum.""" + mock_requests.get( + "https://api.ifpapinball.com/tournament/search", + json={ + "tournaments": [ + { + "tournament_id": 10004, + "tournament_name": "Women's Championship", + "tournament_type": "women", + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.tournament.query().tournament_type(TournamentSearchType.WOMEN).get() + + assert len(result.tournaments) == 1 + assert mock_requests.last_request is not None + assert "tournament_type=women" in mock_requests.last_request.query + + def test_search_with_enum_youth(self, mock_requests: requests_mock.Mocker) -> None: + """Test tournament search using TournamentSearchType.YOUTH enum.""" + mock_requests.get( + "https://api.ifpapinball.com/tournament/search", + json={ + "tournaments": [ + { + "tournament_id": 10005, + "tournament_name": "Youth Championship", + "tournament_type": "youth", + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.tournament.query().tournament_type(TournamentSearchType.YOUTH).get() + + assert len(result.tournaments) == 1 + assert mock_requests.last_request is not None + assert "tournament_type=youth" in mock_requests.last_request.query + + def test_search_with_enum_league(self, mock_requests: requests_mock.Mocker) -> None: + """Test tournament search using TournamentSearchType.LEAGUE enum.""" + mock_requests.get( + "https://api.ifpapinball.com/tournament/search", + json={ + "tournaments": [ + { + "tournament_id": 10006, + "tournament_name": "League Tournament", + "tournament_type": "league", + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.tournament.query().tournament_type(TournamentSearchType.LEAGUE).get() + + assert len(result.tournaments) == 1 + assert mock_requests.last_request is not None + assert "tournament_type=league" in mock_requests.last_request.query + + def test_search_with_enum_open(self, mock_requests: requests_mock.Mocker) -> None: + """Test tournament search using TournamentSearchType.OPEN enum.""" + mock_requests.get( + "https://api.ifpapinball.com/tournament/search", + json={ + "tournaments": [ + { + "tournament_id": 10007, + "tournament_name": "Open Championship", + "tournament_type": "open", + } + ], + "total_results": 1, + }, + ) + + client = IfpaClient(api_key="test-key") + result = client.tournament.query().tournament_type(TournamentSearchType.OPEN).get() + + assert len(result.tournaments) == 1 + assert mock_requests.last_request is not None + assert "tournament_type=open" in mock_requests.last_request.query + def test_search_with_pagination(self, mock_requests: requests_mock.Mocker) -> None: """Test searching tournaments with pagination using query builder.""" mock_requests.get(