From daa14f229ef35d181f1eb948aa4ac8143cb45089 Mon Sep 17 00:00:00 2001 From: John Sosoka Date: Wed, 19 Nov 2025 08:53:14 -0700 Subject: [PATCH 1/5] chore: implement develop branch workflow Set up develop branch workflow with branch protection: - Added develop branch to CI workflow triggers - Updated CONTRIBUTING.md to document develop workflow - Created branch protection setup guide (llm_memory/) - Created async support implementation plan (llm_memory/) Changes: - Feature branches now created from develop - PRs target develop (not main) - main receives periodic releases from develop - CI runs on both main and develop branches --- .github/workflows/ci.yml | 2 ++ CONTRIBUTING.md | 22 ++++++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a5bf44..8fc3051 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,13 @@ on: push: branches: - main + - develop - "feat/**" - "bugfix/**" pull_request: branches: - main + - develop # Cancel in-progress runs for the same workflow and branch concurrency: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb9bbf1..6b365e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,14 +124,19 @@ poetry run pytest ### 1. Create a Feature Branch -Always create a new branch for your work: +We use a **develop branch workflow**: +- `main` - Stable release branch (protected) +- `develop` - Active development integration branch +- Feature branches - Created from and merged back to `develop` + +Always create a new branch for your work from `develop`: ```bash -# Update your main branch -git checkout main -git pull upstream main +# Update your develop branch +git checkout develop +git pull upstream develop -# Create a feature branch +# Create a feature branch from develop git checkout -b feature/your-feature-name # Or for bug fixes @@ -145,6 +150,11 @@ Branch naming conventions: - `refactor/description` - Code refactoring - `test/description` - Test additions/improvements +**Workflow Summary**: +1. Feature branches are created from `develop` +2. Pull requests target `develop` (not `main`) +3. `main` receives periodic releases from `develop` via PR + ### 2. Make Your Changes Follow these guidelines when writing code: @@ -386,7 +396,7 @@ git commit -m "test: add integration tests for tournaments" git push origin feature/your-feature-name ``` -Then create a pull request on GitHub with: +Then create a pull request on GitHub **targeting the `develop` branch** with: 1. **Clear title** describing the change 2. **Description** including: From 74b78a7379909b36232ee2c8123f15a88be3ef8d Mon Sep 17 00:00:00 2001 From: John Sosoka Date: Fri, 21 Nov 2025 19:17:39 -0700 Subject: [PATCH 2/5] fix: correct CI badge URL to use workflow file name The badge was referencing 'workflows/CI' which doesn't match the actual workflow name "Lint, Type Check & Tests". Updated to use the workflow file name (ci.yml) which is more stable and specified branch=main to show the stable release branch status. Changes: - Use actions/workflows/ci.yml/badge.svg instead of workflows/CI - Add ?branch=main parameter to show main branch status --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ca06b6..c2b8735 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![PyPI version](https://img.shields.io/pypi/v/ifpa-api.svg)](https://pypi.org/project/ifpa-api/) [![Python versions](https://img.shields.io/pypi/pyversions/ifpa-api.svg)](https://pypi.org/project/ifpa-api/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![CI](https://github.com/johnsosoka/ifpa-api-python/workflows/CI/badge.svg)](https://github.com/johnsosoka/ifpa-api-python/actions/workflows/ci.yml) +[![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/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) From eafe2816aeab3548efaf7d2701941dff4ce4de8b Mon Sep 17 00:00:00 2001 From: John Sosoka <92633120+johnsosoka@users.noreply.github.com> Date: Sat, 22 Nov 2025 08:38:30 -0700 Subject: [PATCH 3/5] feat: add stats resource with 10 endpoints (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add stats API resource with 10 endpoints Implements comprehensive stats resource providing analytical insights: - Geographic stats (country_players, state_players, state_tournaments) - Historical trends (events_by_year, players_by_year) - Tournament rankings (largest_tournaments, lucrative_tournaments) - Period analytics (points_given_period, events_attended_period) - System metrics (overall) Includes 22 Pydantic models for stats requests/responses. API coverage increased from 36 to 46 endpoints (7 resources total). * test: add comprehensive stats resource tests Adds 1333 lines of unit tests and 642 lines of integration tests for the Stats API resource. Tests cover all 10 endpoints with inline mock data for unit tests and real API calls for integration. Includes stats-specific test fixtures and helpers in conftest.py: - Date range fixtures (90 days, 180 days, full year) - Stats threshold fixtures for validation - Rank type fixtures for parameterized testing - Helper functions for field validation and ranking assertions * docs: add stats mkdocs page * docs: update docs index and navigation for stats resource Updates MkDocs documentation to include Stats resource: - Update API coverage count: 36 → 46 endpoints - Add Stats row to resources table with description - Add Stats to resource navigation links - Update mkdocs.yml navigation to include stats.md * docs: add stats section to README Updates README with Stats resource documentation: - Update API coverage: 36 → 46 endpoints, 6 → 7 resources - Add Stats section with usage examples showing: - Overall IFPA statistics - Top point earners by period - Largest tournaments by country - Player counts by country - Most active players by period * docs: add v0.4.0 changelog entry for stats resource Adds comprehensive v0.4.0 release notes documenting: - 10 new Stats API endpoints (geographic, historical, rankings, period-based) - 22 new Pydantic models for stats responses - 1333 lines of unit tests, 642 lines of integration tests - Stats-specific test fixtures and helpers - Known API issue with WOMEN parameter in overall() endpoint Updates version links for 0.4.0 and 0.3.0 releases. --------- Co-authored-by: John Sosoka --- CHANGELOG.md | 49 +- README.md | 38 +- docs/index.md | 4 +- docs/resources/stats.md | 743 +++++++++++ mkdocs.yml | 1 + src/ifpa_api/client.py | 32 + src/ifpa_api/models/__init__.py | 45 + src/ifpa_api/models/stats.py | 602 +++++++++ src/ifpa_api/resources/__init__.py | 2 + src/ifpa_api/resources/stats/__init__.py | 8 + src/ifpa_api/resources/stats/client.py | 490 +++++++ tests/integration/conftest.py | 224 ++++ tests/integration/test_stats_integration.py | 642 +++++++++ tests/unit/test_stats.py | 1333 +++++++++++++++++++ 14 files changed, 4210 insertions(+), 3 deletions(-) create mode 100644 docs/resources/stats.md create mode 100644 src/ifpa_api/models/stats.py create mode 100644 src/ifpa_api/resources/stats/__init__.py create mode 100644 src/ifpa_api/resources/stats/client.py create mode 100644 tests/integration/test_stats_integration.py create mode 100644 tests/unit/test_stats.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb73b4..ef5ad38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2025-11-21 + +### Added + +**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 +- 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 +634,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..0c5f15a 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,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 +267,42 @@ all_series = client.series.list() active_only = client.series.list(active=True) ``` +### Stats + +```python +# Get overall IFPA statistics +stats = client.stats.overall() +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( + 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(country_code="US") +for tourney in tournaments.stats[:10]: + print(f"{tourney.tournament_name}: {tourney.player_count} players") + +# Get player counts by country +country_stats = client.stats.country_players() +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( + start_date="2024-01-01", + end_date="2024-12-31", + country_code="US", + limit=25 +) +``` + ### Reference Data ```python 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/stats.md b/docs/resources/stats.md new file mode 100644 index 0000000..69ae6de --- /dev/null +++ b/docs/resources/stats.md @@ -0,0 +1,743 @@ +# 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 +from ifpa_api.models.stats import CountryPlayersResponse + +client: IfpaClient = IfpaClient() + +# Get player counts by country +stats: CountryPlayersResponse = client.stats.country_players() +``` + +## Geographic Statistics + +### Player Counts by Country + +Get comprehensive player count statistics for all countries with registered IFPA players: + +```python +from ifpa_api import IfpaClient +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="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 +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="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 +from ifpa_api.models.stats import StatePlayersResponse + +client: IfpaClient = IfpaClient() + +# Get all states with player counts +stats: StatePlayersResponse = client.stats.state_players(rank_type="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 +from ifpa_api.models.stats import StateTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get tournament statistics by state +stats: StateTournamentsResponse = client.stats.state_tournaments(rank_type="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 +from ifpa_api.models.stats import EventsByYearResponse + +client: IfpaClient = IfpaClient() + +# Get global events by year +stats: EventsByYearResponse = client.stats.events_by_year(rank_type="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 +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="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 +from ifpa_api.models.stats import LargestTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get largest tournaments globally +stats: LargestTournamentsResponse = client.stats.largest_tournaments(rank_type="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 +from ifpa_api.models.stats import LargestTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get largest US tournaments +us_stats: LargestTournamentsResponse = client.stats.largest_tournaments( + rank_type="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 +from ifpa_api.models.stats import LucrativeTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get highest-value major tournaments +stats: LucrativeTournamentsResponse = client.stats.lucrative_tournaments( + major="Y", + rank_type="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 +from ifpa_api.models.stats import LucrativeTournamentsResponse + +client: IfpaClient = IfpaClient() + +# Get highest-value major tournaments +major: LucrativeTournamentsResponse = client.stats.lucrative_tournaments(major="Y") + +# Get highest-value non-major tournaments +non_major: LucrativeTournamentsResponse = client.stats.lucrative_tournaments(major="N") + +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 +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="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 +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="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 +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="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 +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="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 +from ifpa_api.models.stats import OverallStatsResponse + +client: IfpaClient = IfpaClient() + +# Get overall IFPA statistics +stats: OverallStatsResponse = client.stats.overall(system_code="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") +``` + +## 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/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/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..2fa2701 100644 --- a/src/ifpa_api/models/__init__.py +++ b/src/ifpa_api/models/__init__.py @@ -76,6 +76,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, @@ -183,4 +206,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/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/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..91eed73 --- /dev/null +++ b/src/ifpa_api/resources/stats/client.py @@ -0,0 +1,490 @@ +"""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.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: 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. Defaults to "OPEN". + + Returns: + CountryPlayersResponse with player counts for each country. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + # Get all countries with player counts + stats = client.stats.country_players(rank_type="OPEN") + for country in stats.stats[:5]: + print(f"{country.country_name}: {country.player_count} players") + + # Get women's rankings by country + women_stats = client.stats.country_players(rank_type="WOMEN") + ``` + """ + params: dict[str, Any] = {} + if rank_type != "OPEN": + params["rank_type"] = rank_type + + response = self._http._request("GET", "/stats/country_players", params=params) + return CountryPlayersResponse.model_validate(response) + + def state_players(self, rank_type: 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. Defaults to "OPEN". + + Returns: + StatePlayersResponse with player counts for each state/province. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + # Get all states with player counts + stats = client.stats.state_players(rank_type="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"]] + ``` + """ + params: dict[str, Any] = {} + if rank_type != "OPEN": + params["rank_type"] = rank_type + + response = self._http._request("GET", "/stats/state_players", params=params) + return StatePlayersResponse.model_validate(response) + + def state_tournaments(self, rank_type: 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. Defaults to "OPEN". + + Returns: + StateTournamentsResponse with tournament counts and point totals. + + Raises: + IfpaApiError: If the API request fails. + + Example: + ```python + # Get tournament statistics by state + stats = client.stats.state_tournaments(rank_type="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}") + ``` + """ + params: dict[str, Any] = {} + if rank_type != "OPEN": + params["rank_type"] = rank_type + + response = self._http._request("GET", "/stats/state_tournaments", params=params) + return StateTournamentsResponse.model_validate(response) + + def events_by_year( + self, + rank_type: 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. 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 + # Get global events by year + stats = client.stats.events_by_year(rank_type="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") + ``` + """ + params: dict[str, Any] = {} + if rank_type != "OPEN": + params["rank_type"] = rank_type + 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: 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. 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 + # Get largest tournaments globally + stats = client.stats.largest_tournaments(rank_type="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") + ``` + """ + params: dict[str, Any] = {} + if rank_type != "OPEN": + params["rank_type"] = rank_type + 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, + major: str = "Y", + rank_type: str = "OPEN", + 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: + major: Filter by major tournament status - "Y" for major tournaments + only (default), "N" for non-major tournaments. + rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for + women's tournaments. Defaults to "OPEN". + 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 + # Get highest-value major tournaments + stats = client.stats.lucrative_tournaments(major="Y", rank_type="OPEN") + 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 + non_major = client.stats.lucrative_tournaments(major="N") + + # Filter by country + us_major = client.stats.lucrative_tournaments(country_code="US") + ``` + """ + params: dict[str, Any] = {} + if major != "Y": + params["major"] = major + if rank_type != "OPEN": + params["rank_type"] = rank_type + 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: 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. 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 + # Get top point earners for 2024 + stats = client.stats.points_given_period( + 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 + ) + ``` + """ + params: dict[str, Any] = {} + if rank_type != "OPEN": + params["rank_type"] = rank_type + 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: 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. 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 + # Get most active players in 2024 + stats = client.stats.events_attended_period( + 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 + ) + ``` + """ + params: dict[str, Any] = {} + if rank_type != "OPEN": + params["rank_type"] = rank_type + 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: 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. 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 + # Get overall IFPA statistics + stats = client.stats.overall(system_code="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}%") + ``` + """ + params: dict[str, Any] = {} + if system_code != "OPEN": + params["system_code"] = system_code + + response = self._http._request("GET", "/stats/overall", params=params) + return OverallStatsResponse.model_validate(response) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1237ec9..69105fd 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,225 @@ 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]: + """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 (100,000+ players, + 2,000+ tournaments per year). If tests fail due to threshold violations, + it may indicate either API changes or these thresholds need updating. + + 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 { + "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": 50, # 50+ tournaments last month + } + + +@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_stats_integration.py b/tests/integration/test_stats_integration.py new file mode 100644 index 0000000..961b5b0 --- /dev/null +++ b/tests/integration/test_stats_integration.py @@ -0,0 +1,642 @@ +"""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.stats import ( + CountryPlayersResponse, + EventsAttendedPeriodResponse, + EventsByYearResponse, + LargestTournamentsResponse, + LucrativeTournamentsResponse, + OverallStatsResponse, + PlayersByYearResponse, + PointsGivenPeriodResponse, + 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 + + +# ============================================================================= +# 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 diff --git a/tests/unit/test_stats.py b/tests/unit/test_stats.py new file mode 100644 index 0000000..214d41f --- /dev/null +++ b/tests/unit/test_stats.py @@ -0,0 +1,1333 @@ +"""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.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) From e5b9aa553e20aa04f21bbb7b836ce74e1f991965 Mon Sep 17 00:00:00 2001 From: John Sosoka <92633120+johnsosoka@users.noreply.github.com> Date: Sat, 22 Nov 2025 10:08:11 -0700 Subject: [PATCH 4/5] Finalize v0.4.0 release (#28) * feat: add type-safe enums for stats parameters Add three new enums for improved type safety and IDE autocomplete: - StatsRankType (OPEN, WOMEN) - used in 8 stats endpoints - SystemCode (OPEN, WOMEN) - used in overall() endpoint - MajorTournament (YES, NO) - used in lucrative_tournaments() All enums maintain full backwards compatibility with string parameters via union types (Enum | str). Changes: - Add enum definitions to models/common.py - Update stats client signatures to accept enums - Export enums from main package - Add 21 new tests (15 enum tests + 6 quality improvements) - Update documentation with enum usage examples 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 * feat: finalize v0.4.0 with verified response sizes and improved test coverage Update version references from 0.3.0 to 0.4.0 across all documentation and source files. Add 6 high-value edge case tests for stats resource and verify response size documentation against live API. Version Updates: - Update pyproject.toml and __init__.py version to 0.4.0 - Update README "What's New" section to reference 0.4.0 - Update installation docs example output to show 0.4.0 Test Improvements: - Add empty response handling test - Add malformed API response validation (2 tests) - Add invalid date format handling test - Add enum/string equivalence test - Add period endpoints smoke test (integration) - Fix overly permissive assertion in validation test Documentation: - Verify response sizes via live API calls (2025-11-22) - Update stats.md with accurate counts for all endpoints - Add verification date and performance tips - Document default 25-result limit for period queries All 84 tests passing, 98% coverage on stats client maintained. --------- Co-authored-by: John Sosoka --- CHANGELOG.md | 33 ++ README.md | 24 +- docs/getting-started/installation.md | 2 +- docs/resources/stats.md | 112 +++-- pyproject.toml | 2 +- src/ifpa_api/__init__.py | 15 +- src/ifpa_api/models/__init__.py | 6 + src/ifpa_api/models/common.py | 38 ++ src/ifpa_api/resources/stats/client.py | 161 +++++--- tests/integration/conftest.py | 17 +- tests/integration/test_stats_integration.py | 234 +++++++++++ tests/unit/test_stats.py | 431 ++++++++++++++++++++ 12 files changed, 973 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5ad38..cbedaad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. @@ -48,6 +80,7 @@ The Stats API was documented in v0.1.0 as returning 404 errors from the live API ### 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 diff --git a/README.md b/README.md index 0c5f15a..e19cb66 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A typed Python client for the [IFPA (International Flipper Pinball Association) **Complete documentation**: https://johnsosoka.github.io/ifpa-api-python/ -## What's New in 0.3.0 +## What's New in 0.4.0 **Quality of Life Improvements** - Enhanced debugging, pagination, and error handling: @@ -25,6 +25,9 @@ from ifpa_api import ( IfpaApiError, SeriesPlayerNotFoundError, TournamentNotLeagueError, + StatsRankType, + MajorTournament, + SystemCode, ) # 1. Enhanced Error Messages - Full request context in exceptions @@ -270,13 +273,18 @@ 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() +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 @@ -285,17 +293,21 @@ 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(country_code="US") +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 -country_stats = client.stats.country_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", @@ -303,6 +315,8 @@ active_players = client.stats.events_attended_period( ) ``` +**Type Safety**: Stats methods accept typed enums (e.g., `StatsRankType.WOMEN`) or strings for backwards compatibility. + ### Reference Data ```python 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/resources/stats.md b/docs/resources/stats.md index 69ae6de..5a8368b 100644 --- a/docs/resources/stats.md +++ b/docs/resources/stats.md @@ -5,15 +5,29 @@ The Stats resource provides access to IFPA statistical data including geographic ## Quick Example ```python -from ifpa_api import IfpaClient +from ifpa_api import IfpaClient, StatsRankType from ifpa_api.models.stats import CountryPlayersResponse client: IfpaClient = IfpaClient() -# Get player counts by country -stats: CountryPlayersResponse = client.stats.country_players() +# 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 @@ -21,13 +35,13 @@ stats: CountryPlayersResponse = client.stats.country_players() Get comprehensive player count statistics for all countries with registered IFPA players: ```python -from ifpa_api import IfpaClient +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="OPEN") +stats: CountryPlayersResponse = client.stats.country_players(rank_type=StatsRankType.OPEN) print(f"Type: {stats.type}") print(f"Rank Type: {stats.rank_type}") @@ -53,13 +67,13 @@ for country in stats.stats[:5]: You can also query women's rankings: ```python -from ifpa_api import IfpaClient +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="WOMEN") +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") @@ -70,13 +84,13 @@ for country in women_stats.stats[:5]: Get player count statistics for North American states and provinces: ```python -from ifpa_api import IfpaClient +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="OPEN") +stats: StatePlayersResponse = client.stats.state_players(rank_type=StatsRankType.OPEN) print(f"Type: {stats.type}") print(f"Rank Type: {stats.rank_type}") @@ -98,13 +112,13 @@ for state in west_coast: Get detailed tournament statistics including counts and WPPR points awarded by state: ```python -from ifpa_api import IfpaClient +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="OPEN") +stats: StateTournamentsResponse = client.stats.state_tournaments(rank_type=StatsRankType.OPEN) print(f"Type: {stats.type}") print(f"Rank Type: {stats.rank_type}") @@ -124,13 +138,13 @@ for state in stats.stats[:5]: Track yearly growth trends in international pinball competition: ```python -from ifpa_api import IfpaClient +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="OPEN") +stats: EventsByYearResponse = client.stats.events_by_year(rank_type=StatsRankType.OPEN) print(f"Type: {stats.type}") print(f"Rank Type: {stats.rank_type}") @@ -157,14 +171,14 @@ for year in stats.stats[:5]: You can filter by country: ```python -from ifpa_api import IfpaClient +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="OPEN", + rank_type=StatsRankType.OPEN, country_code="US" ) @@ -219,13 +233,13 @@ for year in stats.stats[:5]: Get the top 25 tournaments in IFPA history by player count: ```python -from ifpa_api import IfpaClient +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="OPEN") +stats: LargestTournamentsResponse = client.stats.largest_tournaments(rank_type=StatsRankType.OPEN) print(f"Type: {stats.type}") print(f"Rank Type: {stats.rank_type}") @@ -251,14 +265,14 @@ for tourney in stats.stats[:10]: Filter by country: ```python -from ifpa_api import IfpaClient +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="OPEN", + rank_type=StatsRankType.OPEN, country_code="US" ) ``` @@ -268,15 +282,15 @@ us_stats: LargestTournamentsResponse = client.stats.largest_tournaments( Get the top 25 tournaments by tournament value (WPPR rating), which correlates with competitive prestige: ```python -from ifpa_api import IfpaClient +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="Y", - rank_type="OPEN" + major=MajorTournament.YES, + rank_type=StatsRankType.OPEN ) print(f"Type: {stats.type}") @@ -303,16 +317,16 @@ for tourney in stats.stats[:10]: Compare major vs non-major tournaments: ```python -from ifpa_api import IfpaClient +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="Y") +major: LucrativeTournamentsResponse = client.stats.lucrative_tournaments(major=MajorTournament.YES) # Get highest-value non-major tournaments -non_major: LucrativeTournamentsResponse = client.stats.lucrative_tournaments(major="N") +non_major: LucrativeTournamentsResponse = client.stats.lucrative_tournaments(major=MajorTournament.NO) print("Major Tournaments:") for t in major.stats[:3]: @@ -330,14 +344,14 @@ for t in non_major.stats[:3]: Get players with the most accumulated WPPR points over a specific time period: ```python -from ifpa_api import IfpaClient +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="OPEN", + rank_type=StatsRankType.OPEN, start_date="2024-01-01", end_date="2024-12-31", limit=25 @@ -369,14 +383,14 @@ for player in stats.stats[:10]: Filter by country: ```python -from ifpa_api import IfpaClient +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="OPEN", + rank_type=StatsRankType.OPEN, country_code="US", start_date="2024-01-01", end_date="2024-12-31", @@ -392,14 +406,14 @@ for player in us_stats.stats: Get players who attended the most tournaments during a specific time period: ```python -from ifpa_api import IfpaClient +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="OPEN", + rank_type=StatsRankType.OPEN, start_date="2024-01-01", end_date="2024-12-31", limit=25 @@ -430,14 +444,14 @@ for player in stats.stats[:10]: Filter by country: ```python -from ifpa_api import IfpaClient +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="OPEN", + rank_type=StatsRankType.OPEN, country_code="US", start_date="2024-01-01", end_date="2024-12-31", @@ -450,13 +464,13 @@ us_stats: EventsAttendedPeriodResponse = client.stats.events_attended_period( Get comprehensive aggregate statistics about the entire IFPA system: ```python -from ifpa_api import IfpaClient +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="OPEN") +stats: OverallStatsResponse = client.stats.overall(system_code=SystemCode.OPEN) print(f"Type: {stats.type}") print(f"System Code: {stats.system_code}") @@ -715,6 +729,34 @@ 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: diff --git a/pyproject.toml b/pyproject.toml index 42dc170..77c633d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [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" diff --git a/src/ifpa_api/__init__.py b/src/ifpa_api/__init__.py index ef5feda..12b25c8 100644 --- a/src/ifpa_api/__init__.py +++ b/src/ifpa_api/__init__.py @@ -43,9 +43,17 @@ SeriesPlayerNotFoundError, TournamentNotLeagueError, ) -from ifpa_api.models.common import RankingSystem, ResultType, TimePeriod, TournamentType +from ifpa_api.models.common import ( + MajorTournament, + RankingSystem, + ResultType, + StatsRankType, + SystemCode, + TimePeriod, + TournamentType, +) -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = [ # Main client @@ -55,6 +63,9 @@ "RankingSystem", "ResultType", "TournamentType", + "StatsRankType", + "SystemCode", + "MajorTournament", # Exceptions "IfpaError", "IfpaApiError", diff --git a/src/ifpa_api/models/__init__.py b/src/ifpa_api/models/__init__.py index 2fa2701..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, ) @@ -127,6 +130,9 @@ "RankingSystem", "ResultType", "TournamentType", + "StatsRankType", + "SystemCode", + "MajorTournament", # Director "Director", "DirectorStats", diff --git a/src/ifpa_api/models/common.py b/src/ifpa_api/models/common.py index 866f3b3..cdc68a2 100644 --- a/src/ifpa_api/models/common.py +++ b/src/ifpa_api/models/common.py @@ -65,6 +65,44 @@ 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 IfpaBaseModel(BaseModel): """Base model for all IFPA SDK Pydantic models. diff --git a/src/ifpa_api/resources/stats/client.py b/src/ifpa_api/resources/stats/client.py index 91eed73..9de2f06 100644 --- a/src/ifpa_api/resources/stats/client.py +++ b/src/ifpa_api/resources/stats/client.py @@ -7,6 +7,7 @@ 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, @@ -41,7 +42,7 @@ class StatsClient(BaseResourceClient): _validate_requests: Whether to validate request parameters """ - def country_players(self, rank_type: str = "OPEN") -> CountryPlayersResponse: + 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, @@ -49,7 +50,7 @@ def country_players(self, rank_type: str = "OPEN") -> CountryPlayersResponse: Args: rank_type: Ranking type - "OPEN" for all players or "WOMEN" for - women's rankings. Defaults to "OPEN". + women's rankings. Accepts StatsRankType enum or string. Defaults to "OPEN". Returns: CountryPlayersResponse with player counts for each country. @@ -59,23 +60,28 @@ def country_players(self, rank_type: str = "OPEN") -> CountryPlayersResponse: Example: ```python - # Get all countries with player counts - stats = client.stats.country_players(rank_type="OPEN") + 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 + # 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_type != "OPEN": - params["rank_type"] = rank_type + 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: str = "OPEN") -> StatePlayersResponse: + 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 @@ -83,7 +89,7 @@ def state_players(self, rank_type: str = "OPEN") -> StatePlayersResponse: Args: rank_type: Ranking type - "OPEN" for all players or "WOMEN" for - women's rankings. Defaults to "OPEN". + women's rankings. Accepts StatsRankType enum or string. Defaults to "OPEN". Returns: StatePlayersResponse with player counts for each state/province. @@ -93,8 +99,10 @@ def state_players(self, rank_type: str = "OPEN") -> StatePlayersResponse: Example: ```python + from ifpa_api import StatsRankType + # Get all states with player counts - stats = client.stats.state_players(rank_type="OPEN") + stats = client.stats.state_players(rank_type=StatsRankType.OPEN) for state in stats.stats[:5]: print(f"{state.stateprov}: {state.player_count} players") @@ -102,14 +110,19 @@ def state_players(self, rank_type: str = "OPEN") -> StatePlayersResponse: 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_type != "OPEN": - params["rank_type"] = rank_type + 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: str = "OPEN") -> StateTournamentsResponse: + 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 @@ -117,7 +130,7 @@ def state_tournaments(self, rank_type: str = "OPEN") -> StateTournamentsResponse Args: rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for - women's tournaments. Defaults to "OPEN". + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". Returns: StateTournamentsResponse with tournament counts and point totals. @@ -127,24 +140,29 @@ def state_tournaments(self, rank_type: str = "OPEN") -> StateTournamentsResponse Example: ```python + from ifpa_api import StatsRankType + # Get tournament statistics by state - stats = client.stats.state_tournaments(rank_type="OPEN") + 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_type != "OPEN": - params["rank_type"] = rank_type + 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: str = "OPEN", + rank_type: StatsRankType | str = "OPEN", country_code: str | None = None, ) -> EventsByYearResponse: """Get statistics about number of events per year. @@ -154,7 +172,7 @@ def events_by_year( Args: rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for - women's tournaments. Defaults to "OPEN". + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". country_code: Optional country code to filter by (e.g., "US", "CA"). Returns: @@ -165,8 +183,10 @@ def events_by_year( Example: ```python + from ifpa_api import StatsRankType + # Get global events by year - stats = client.stats.events_by_year(rank_type="OPEN") + 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}") @@ -176,9 +196,12 @@ def events_by_year( 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_type != "OPEN": - params["rank_type"] = rank_type + if rank_value != "OPEN": + params["rank_type"] = rank_value if country_code is not None: params["country_code"] = country_code @@ -219,7 +242,7 @@ def players_by_year(self) -> PlayersByYearResponse: def largest_tournaments( self, - rank_type: str = "OPEN", + rank_type: StatsRankType | str = "OPEN", country_code: str | None = None, ) -> LargestTournamentsResponse: """Get top 25 tournaments by player count. @@ -229,7 +252,7 @@ def largest_tournaments( Args: rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for - women's tournaments. Defaults to "OPEN". + women's tournaments. Accepts StatsRankType enum or string. Defaults to "OPEN". country_code: Optional country code to filter by (e.g., "US", "CA"). Returns: @@ -240,8 +263,10 @@ def largest_tournaments( Example: ```python + from ifpa_api import StatsRankType + # Get largest tournaments globally - stats = client.stats.largest_tournaments(rank_type="OPEN") + 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") @@ -251,9 +276,12 @@ def largest_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_type != "OPEN": - params["rank_type"] = rank_type + if rank_value != "OPEN": + params["rank_type"] = rank_value if country_code is not None: params["country_code"] = country_code @@ -262,8 +290,8 @@ def largest_tournaments( def lucrative_tournaments( self, - major: str = "Y", - rank_type: str = "OPEN", + rank_type: StatsRankType | str = "OPEN", + major: MajorTournament | str = "Y", country_code: str | None = None, ) -> LucrativeTournamentsResponse: """Get top 25 tournaments by tournament value (WPPR rating). @@ -272,10 +300,11 @@ def lucrative_tournaments( the most competitive and prestigious events. Args: - major: Filter by major tournament status - "Y" for major tournaments - only (default), "N" for non-major tournaments. rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for - women's tournaments. Defaults to "OPEN". + 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: @@ -286,25 +315,34 @@ def lucrative_tournaments( Example: ```python - # Get highest-value major tournaments - stats = client.stats.lucrative_tournaments(major="Y", rank_type="OPEN") + 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 + # 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 != "Y": - params["major"] = major - if rank_type != "OPEN": - params["rank_type"] = rank_type + 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 @@ -313,7 +351,7 @@ def lucrative_tournaments( def points_given_period( self, - rank_type: str = "OPEN", + rank_type: StatsRankType | str = "OPEN", country_code: str | None = None, start_date: str | None = None, end_date: str | None = None, @@ -326,7 +364,7 @@ def points_given_period( Args: rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for - women's tournaments. Defaults to "OPEN". + 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. @@ -343,8 +381,11 @@ def points_given_period( 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 @@ -360,9 +401,12 @@ def points_given_period( ) ``` """ + # 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_type != "OPEN": - params["rank_type"] = rank_type + 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: @@ -377,7 +421,7 @@ def points_given_period( def events_attended_period( self, - rank_type: str = "OPEN", + rank_type: StatsRankType | str = "OPEN", country_code: str | None = None, start_date: str | None = None, end_date: str | None = None, @@ -390,7 +434,7 @@ def events_attended_period( Args: rank_type: Ranking type - "OPEN" for all tournaments or "WOMEN" for - women's tournaments. Defaults to "OPEN". + 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. @@ -407,8 +451,11 @@ def events_attended_period( 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 @@ -425,9 +472,12 @@ def events_attended_period( ) ``` """ + # 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_type != "OPEN": - params["rank_type"] = rank_type + 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: @@ -440,7 +490,7 @@ def events_attended_period( response = self._http._request("GET", "/stats/events_attended_period", params=params) return EventsAttendedPeriodResponse.model_validate(response) - def overall(self, system_code: str = "OPEN") -> OverallStatsResponse: + def overall(self, system_code: SystemCode | str = "OPEN") -> OverallStatsResponse: """Get overall WPPR system statistics. Returns aggregate statistics about the entire IFPA system including @@ -451,7 +501,7 @@ def overall(self, system_code: str = "OPEN") -> OverallStatsResponse: Args: system_code: Ranking system - "OPEN" for open division or "WOMEN" - for women's division. Defaults to "OPEN". + for women's division. Accepts SystemCode enum or string. Defaults to "OPEN". Returns: OverallStatsResponse with comprehensive IFPA system statistics. @@ -465,8 +515,10 @@ def overall(self, system_code: str = "OPEN") -> OverallStatsResponse: Example: ```python - # Get overall IFPA statistics - stats = client.stats.overall(system_code="OPEN") + 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}") @@ -482,9 +534,12 @@ def overall(self, system_code: str = "OPEN") -> OverallStatsResponse: 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_code != "OPEN": - params["system_code"] = system_code + 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/tests/integration/conftest.py b/tests/integration/conftest.py index 69105fd..45291b7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -399,15 +399,20 @@ def test_annual_activity(client, stats_date_range_last_year): @pytest.fixture -def stats_thresholds() -> dict[str, int]: +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 (100,000+ players, - 2,000+ tournaments per year). If tests fail due to threshold violations, - it may indicate either API changes or these thresholds need updating. + 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 @@ -418,11 +423,13 @@ def test_overall_stats_reasonable(client, stats_thresholds): ``` """ 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": 50, # 50+ tournaments last month + "tournament_count_last_month": 30, # 30+ tournaments (lowered for seasonal variation) } diff --git a/tests/integration/test_stats_integration.py b/tests/integration/test_stats_integration.py index 961b5b0..c1c0213 100644 --- a/tests/integration/test_stats_integration.py +++ b/tests/integration/test_stats_integration.py @@ -25,6 +25,7 @@ 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, @@ -34,6 +35,7 @@ OverallStatsResponse, PlayersByYearResponse, PointsGivenPeriodResponse, + PointsGivenPeriodStat, StatePlayersResponse, StateTournamentsResponse, ) @@ -421,6 +423,64 @@ def test_events_attended_period( 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 @@ -640,3 +700,177 @@ def test_overall_invalid_system_code(self, client: IfpaClient) -> None: 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/unit/test_stats.py b/tests/unit/test_stats.py index 214d41f..75414ad 100644 --- a/tests/unit/test_stats.py +++ b/tests/unit/test_stats.py @@ -8,6 +8,7 @@ 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, @@ -1331,3 +1332,433 @@ def test_overall_returns_proper_numeric_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() From 6242f1505b3b9c33c38bca3c0dc2c76d3b523336 Mon Sep 17 00:00:00 2001 From: John Sosoka Date: Sat, 22 Nov 2025 11:09:38 -0700 Subject: [PATCH 5/5] feat: add ReadTheDocs integration and type-safe enums - Configure ReadTheDocs with Poetry dependency management - Add RankingDivision enum for Rankings resource - Add TournamentSearchType enum for Tournament searches - Update test to handle intermittent API behavior - Reorganize docs dependencies into dedicated Poetry group --- .github/workflows/docs.yml | 23 ++-- .readthedocs.yaml | 48 +++++++++ CHANGELOG.md | 68 ++++++++++++ README.md | 100 ++++++------------ docs/resources/rankings.md | 38 ++++--- docs/resources/tournaments.md | 21 +++- poetry.lock | 2 +- pyproject.toml | 10 +- src/ifpa_api/__init__.py | 4 + src/ifpa_api/models/common.py | 53 ++++++++++ src/ifpa_api/resources/rankings/client.py | 65 +++++++++--- .../resources/tournament/query_builder.py | 26 ++++- .../integration/test_rankings_integration.py | 41 ++++++- .../test_tournament_integration.py | 97 +++++++++++++++-- tests/unit/test_rankings.py | 97 +++++++++++++++++ tests/unit/test_tournaments.py | 93 ++++++++++++++++ 16 files changed, 658 insertions(+), 128 deletions(-) create mode 100644 .readthedocs.yaml 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 cbedaad..8c0b151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,74 @@ 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 diff --git a/README.md b/README.md index e19cb66..b2d2804 100644 --- a/README.md +++ b/README.md @@ -6,95 +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.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, - StatsRankType, - MajorTournament, - SystemCode, -) - -# 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 +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. -# 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}") - -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 @@ -539,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/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/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/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 77c633d..fe302a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ 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 12b25c8..bcb30f6 100644 --- a/src/ifpa_api/__init__.py +++ b/src/ifpa_api/__init__.py @@ -45,11 +45,13 @@ ) from ifpa_api.models.common import ( MajorTournament, + RankingDivision, RankingSystem, ResultType, StatsRankType, SystemCode, TimePeriod, + TournamentSearchType, TournamentType, ) @@ -66,6 +68,8 @@ "StatsRankType", "SystemCode", "MajorTournament", + "RankingDivision", + "TournamentSearchType", # Exceptions "IfpaError", "IfpaApiError", diff --git a/src/ifpa_api/models/common.py b/src/ifpa_api/models/common.py index cdc68a2..b81b6d1 100644 --- a/src/ifpa_api/models/common.py +++ b/src/ifpa_api/models/common.py @@ -103,6 +103,59 @@ class MajorTournament(str, Enum): 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/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/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/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_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_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(