fplkit 2.5.0__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fplkit-2.5.0 → fplkit-2.6.0}/CHANGELOG.md +37 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/PKG-INFO +1 -1
- {fplkit-2.5.0 → fplkit-2.6.0}/docs/architecture.md +3 -3
- {fplkit-2.5.0 → fplkit-2.6.0}/docs/command-reference.md +23 -8
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/_version.py +2 -2
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/_models.py +25 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/anthropic.py +19 -1
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/understat.py +228 -62
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_context.py +80 -2
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/doctor.py +64 -24
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/doctor_providers.py +11 -3
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/ratings.py +1 -1
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/review.py +35 -12
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/season.py +24 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/returnee_radar.py +7 -3
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/templates/gw_review.md.j2 +7 -2
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_understat.py +235 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_doctor.py +101 -9
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_doctor_providers.py +15 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_format.py +85 -1
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_review.py +39 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_paths.py +88 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_providers.py +91 -1
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_report_agent.py +59 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_returnee_radar.py +41 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_review.py +43 -1
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_season.py +21 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_team_ratings.py +3 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/README.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/TOOLS.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/build-test-plan/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/create-pr/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/references/output-template.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/references/rules.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/preview-ingest/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/release/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/release-notes/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/squad-builder/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/squad-builder/references/output-template.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/squad-builder/references/rules.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/update-gw-prep/SKILL.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.claude/output-styles/fpl-mate.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.claude/settings.json +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.env.example +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.github/workflows/ci.yml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.github/workflows/pr-title.yml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.github/workflows/release.yml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/.gitignore +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/AGENTS.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/CLAUDE.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/CONTRIBUTING.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/LICENSE +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/README.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/cliff.toml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/config/team_ratings_overrides.yaml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/docs/custom-analysis.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/docs/fpl-rules.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/docs/images/fpl-player-demo-v1-0.png +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/action/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/action/waiver.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/bench_order.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/captain.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/starting_xi.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/stats.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/base.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/common.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/fixture.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/price.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/scout.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/orchestration/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/orchestration/report.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/contract.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/core_insights.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/dataset_fetcher.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/football_data.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/fpl.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/fpl_draft.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/historical.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/historical_types.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/_http.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/openai_compat.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/perplexity.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/vaastav.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_banner.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_fines.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_fines_config.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_helpers.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_json.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_league_recap_data.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_league_recap_history.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_league_recap_types.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_plan_grid.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_analysis.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_classic.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_draft.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_summarisation.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/allocate.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/captain.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/chips.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/credentials.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/differentials.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/fdr.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/fixtures.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/history.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/init.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/intel.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/league.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/league_fines.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/league_recap.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/player.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/preview.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/price_changes.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/price_history.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/returnees.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/review.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/sell_prices.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/squad.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/stats.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/status.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/targets.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/transfer_eval.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/waivers.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/xg.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/defaults.yaml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/fixture_predictions.yaml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/team_managers.yaml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/constants.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/chip_plan.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/fixture.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/league_history.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/player.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/team.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/types.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/parsers/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/parsers/recommendations.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/paths.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/league_recap.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/returnees.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/scout.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/scraper/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/scraper/fpl_prices.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/fixture_predictions.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history_counters.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history_fines.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history_notes.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/matchup.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/player_clubs.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/player_prior.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/constants.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/data_prep.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/display.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/evaluation.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/ownership.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/shrinkage.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/signals.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/single_gw.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/value_quality.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/season_previews.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/squad_allocator.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/team_form.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/team_ratings.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/team_ratings_prior.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/templates/gw_preview.md.j2 +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/files.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/gameweek.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/markdown.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/teams.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/text.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/time.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/pyproject.toml +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/pyrightconfig.json +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/scripts/backtest_early_season_prior_blend.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/scripts/calibrate_promoted_prior.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/scripts/calibrate_quality_ceilings.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/__init__.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/conftest.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/classic_squad_fixture.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/recommendations_fixture.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/waiver_ranked_golden.json +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_action.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_analysis.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_analysis_squad.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_base.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_data.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_data_price.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_orchestration.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_allocate.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_client.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_core_insights.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_dataset_fetcher.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_draft.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_football_data.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_historical.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_vaastav.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_bench_order_script.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_calibrate_quality_ceilings.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_classic_squad_extraction.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_allocate.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_banner.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_captain.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_chips.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_context_settings.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_credentials.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_custom_analysis.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_differentials.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_failure_parity.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_failure_streams.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fdr.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fdr_blanks.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fixtures.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_format_suppression.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fpl_block.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_history.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_init.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_intel.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_json.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_json_contract.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_league.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_league_fines.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_league_recap.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_plan_grid.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_player.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_preview.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_price_changes.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_price_history.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_returnees.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_sell_prices.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_settings_warning.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_squad.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_stats.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_status.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_targets.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_transfer_eval.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_waivers.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_warnings_stderr.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_xg.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_common.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_common_scoring.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_data_prep_harmonisation.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_dependencies.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_doc_consistency.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fines.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fines_config.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fixture_predictions.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fpl_prices.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_gw_prep_script_bootstrap.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_init.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_init_fines.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_counters.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_fines.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_notes.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_store.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_recap.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_matchup_service.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_models.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_normalise_entities.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_output_style_sync.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_player_clubs.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_player_prior.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_player_resolution.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_preseason.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_prompt_tables.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_recommendations_parser.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_report_paths.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_rolling_pts_per_m.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_scoring.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_scraper.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_season_awareness.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_season_previews.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_sell_prices.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_skill_fdr_quality_signals.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_skill_stats_thresholds.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_squad_allocator.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_starting_xi_agent.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_starting_xi_script.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_stats.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_team_ratings_prior.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_transfer_eval_agent.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_transfer_eval_script.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_gameweek.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_markdown.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_text.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_time.py +0 -0
- {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_validate_draft_waivers.py +0 -0
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
## [2.5.0] - 2026-09-07
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- stop the fpl-mate output style calling ep_next prior-informed (#261)
|
|
9
|
+
- restore xG enrichment for players with an apostrophe in their name (#268)
|
|
10
|
+
- keep coverage readable when one ledger file is damaged (#271)
|
|
11
|
+
- stop the review scrubber deleting sentences naming full names (#270)
|
|
12
|
+
- recap the last completed gameweek once the next one has kicked off (#274)
|
|
13
|
+
- stop fpl xg table output crashing when custom analysis is off (#275)
|
|
14
|
+
- cap httpx, pydantic and click below their next majors (#278)
|
|
15
|
+
- stop a truncated LLM summary being saved without a warning (#276)
|
|
16
|
+
- stop a draft replay erasing the league positions it recorded (#280)
|
|
17
|
+
- validate FPL_CLI_DATA_DIR and FPL_CLI_CACHE_DIR eagerly like FPL_CLI_CONFIG_DIR (#281)
|
|
18
|
+
- raise the click floor to 8.2 so the test suite can capture stderr (#283)
|
|
19
|
+
- collapse a league-wide captain tie into one recap line (#282)
|
|
20
|
+
- fill the blank Source column in a review's Standout Performers (#284)
|
|
21
|
+
- exit 1 with the real reason when review or recap cannot run (#285)
|
|
22
|
+
- point doctor's draft ID fix hints at `fpl init` (#288)
|
|
23
|
+
- keep the current table's points out of a replayed gameweek's recap (#293)
|
|
24
|
+
- report a failure the same way whatever the output format (#292)
|
|
25
|
+
- keep league positions when one manager's replay data is partial (#295)
|
|
26
|
+
- stop a replay renaming the player a stored red-card fine names (#290)
|
|
27
|
+
- ground the review's "Next Week" advice in next gameweek's fixtures (#291)
|
|
28
|
+
- rebuild the ratings prior when a football-data key appears (#289)
|
|
29
|
+
- record the club a player played for in a replayed gameweek (#296)
|
|
30
|
+
- keep `review --summarise` running when an LLM key is missing (#297)
|
|
31
|
+
- derive season labels from GW1's deadline instead of the clock (#299)
|
|
32
|
+
- correct award move direction and free-agent mislabelling (#302)
|
|
33
|
+
|
|
34
|
+
### Features
|
|
35
|
+
|
|
36
|
+
- flag stale quality-score calibration in `fpl doctor` (#298)
|
|
37
|
+
- hand the league-recap editorial every manager's transfers (#300)
|
|
38
|
+
- give the draft recap editorial every waiver and free-agent move (#303)
|
|
39
|
+
- give the season-opener recap each manager's prior FPL seasons (#304)
|
|
40
|
+
|
|
4
41
|
## [2.4.1] - 2026-09-05
|
|
5
42
|
|
|
6
43
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fplkit
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: CLI tool for Fantasy Premier League analysis - classic and draft formats
|
|
5
5
|
Project-URL: Homepage, https://github.com/rossgroomio/fpl-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/rossgroomio/fpl-cli
|
|
@@ -434,7 +434,7 @@ classDiagram
|
|
|
434
434
|
|
|
435
435
|
All providers share the `LLMResponse` contract. `OpenAICompatProvider` supports OpenAI, Groq, Together, Ollama via configurable `base_url`. Provider selection configured in settings.
|
|
436
436
|
|
|
437
|
-
`stop_reason` carries the provider's own verdict on why generation ended, verbatim in whichever vocabulary it used — Anthropic's `stop_reason`, an OpenAI-compatible API's `finish_reason`. `stopped_early` reads it against `NORMAL_STOP_REASONS`, a single set covering both dialects (the vocabularies do not collide), and every provider announces an abnormal one at WARNING via `log_abnormal_stop` — the same reason `_http` announces its retries there, so a truncation is visible on any command that talks to an LLM. `None` means the provider said nothing, which is never treated as evidence of truncation. Without this a cut-off response and a complete one were indistinguishable downstream, which is how a saved gameweek review shipped missing a whole format's verdict at exit 0 (#266).
|
|
437
|
+
`stop_reason` carries the provider's own verdict on why generation ended, verbatim in whichever vocabulary it used — Anthropic's `stop_reason`, an OpenAI-compatible API's `finish_reason`. `stopped_early` reads it against `NORMAL_STOP_REASONS`, a single set covering both dialects (the vocabularies do not collide), and every provider announces an abnormal one at WARNING via `log_abnormal_stop` — the same reason `_http` announces its retries there, so a truncation is visible on any command that talks to an LLM. `None` means the provider said nothing, which is never treated as evidence of truncation. Without this a cut-off response and a complete one were indistinguishable downstream, which is how a saved gameweek review shipped missing a whole format's verdict at exit 0 (#266). `log_textless_response` is its companion for the other half of that failure: a reasoning model that reaches the ceiling mid-thought returns content with no text block in it at all, so the concatenated prose is `""` — honest, but indistinguishable from a model with nothing to say until the non-text blocks that did arrive are named (#306). Unlike `log_abnormal_stop` this one is Anthropic-only, because only the Messages API returns typed content blocks — an OpenAI-compatible reply carries a plain `message.content` string, so there is nothing to name when it comes back empty. What catches an empty answer whatever produced it is `check_synthesis_completeness`'s `empty` flag, one layer along, which reads the string the report would carry rather than the envelope it arrived in.
|
|
438
438
|
|
|
439
439
|
Both HTTP providers post through `_http.post_json_with_retry`, the one place their error handling lives: it retries an HTTP 429 with `RetryPolicy` (exponential backoff with jitter, `Retry-After` honoured, the whole wait bounded by one budget, each retry announced at WARNING) and raises `RateLimitError` — a `ProviderError` subclass carrying the server's `retry_after` — once the attempts are spent, and turns every other error status, a timeout, or a non-JSON body into a sanitised `ProviderError`. That is the same 429-is-transient distinction `DatasetFetcher` draws for the historical datasets. The module also holds `QueryPacer`, which keeps request starts a configured interval apart for a caller that fires several queries at once; `fpl returnees --enrich` pairs it with an in-flight cap, reads the typed error to re-query only the rate-limited subset of its shortlist once, and reports a still-refused player as rate-limited rather than unanswered.
|
|
440
440
|
|
|
@@ -555,7 +555,7 @@ fpl_cli/
|
|
|
555
555
|
│ ├── historical.py # HistoricalDataProvider (composition: vaastav + Core-Insights) + historical_season_windows() (the disjoint source allocation) + merge_season_histories()
|
|
556
556
|
│ ├── football_data.py # FootballDataClient (standings, match results)
|
|
557
557
|
│ └── providers/ # LLM provider abstraction
|
|
558
|
-
│ ├── _models.py # LLMResponse (incl. stop_reason/stopped_early), TokenUsage, NORMAL_STOP_REASONS, log_abnormal_stop, ProviderError, RateLimitError
|
|
558
|
+
│ ├── _models.py # LLMResponse (incl. stop_reason/stopped_early), TokenUsage, NORMAL_STOP_REASONS, log_abnormal_stop, log_textless_response, ProviderError, RateLimitError
|
|
559
559
|
│ ├── _http.py # Shared HTTP plumbing: 429 backoff (RetryPolicy, post_with_retry), the providers' one error path (post_json_with_retry), QueryPacer for batching callers
|
|
560
560
|
│ ├── anthropic.py # AnthropicProvider
|
|
561
561
|
│ ├── openai_compat.py # OpenAICompatProvider (OpenAI, Groq, Together, Ollama)
|
|
@@ -602,7 +602,7 @@ fpl_cli/
|
|
|
602
602
|
├── scraper/
|
|
603
603
|
│ └── fpl_prices.py # FPLPriceScraper (needs FPL_EMAIL/FPL_PASSWORD; behind TLS-inspecting proxies: FPL_BROWSER_IGNORE_CERTS=1 for cert MITM, or FPL_BROWSER_EXECUTABLE/CHANNEL/ARGS to swap the browser when the ClientHello itself is rejected)
|
|
604
604
|
├── paths.py # SHIPPED_CONFIG_DIR, TEMPLATE_DIR, user_config_dir(), user_data_dir(), user_cache_dir() — each user_* dir overridable via FPL_CLI_CONFIG_DIR / FPL_CLI_DATA_DIR / FPL_CLI_CACHE_DIR (absolute paths only; a relative one raises UserDirError rather than resolving against the cwd)
|
|
605
|
-
├── season.py # season_label() (+ vaastav_season() alias), previous_season_year()/previous_season_label() (the newest *completed* season, one expression for the player prior, the calibration script's default and doctor's frozen-anchor check), understat_season(), core_insights_season(), TOTAL_GAMEWEEKS, CHIP_SPLIT_GW, PROMOTED_CLUBS_PER_SEASON. season_year_from_gameweeks() derives the year from GW1's deadline rather than the July-cutover clock (#91); resolve_season_year() composes it with the clock — GW1's year while its season is still live, the clock's once that season has fully concluded and the calendar has moved past it (the close-season gap before the next one's fixtures publish, when bootstrap-static keeps serving the finished season's events untouched). FPLClient.get_season_year() wraps resolve_season_year(), and review/league-recap/preview/status pass the result through explicitly so a season overrunning the cutover (2019-20, delayed into July 2020) still resolves to the year it started; league-fines, which reads the ledger with no network, instead defaults to services/league_history.py's most_recent_captured_season() so its default agrees with whatever league-recap actually wrote
|
|
605
|
+
├── season.py # season_label() (+ vaastav_season() alias), previous_season_year()/previous_season_label() (the newest *completed* season, one expression for the player prior, the calibration script's default and doctor's frozen-anchor check), understat_season(), core_insights_season(), TOTAL_GAMEWEEKS, CHIP_SPLIT_GW, PROMOTED_CLUBS_PER_SEASON. season_year_from_gameweeks() derives the year from GW1's deadline rather than the July-cutover clock (#91); resolve_season_year() composes it with the clock — GW1's year while its season is still live, the clock's once that season has fully concluded and the calendar has moved past it (the close-season gap before the next one's fixtures publish, when bootstrap-static keeps serving the finished season's events untouched). FPLClient.get_season_year() wraps resolve_season_year(), and review/league-recap/preview/status/doctor pass the result through explicitly so a season overrunning the cutover (2019-20, delayed into July 2020) still resolves to the year it started -- doctor falls back to the clock only when the API is unreachable, and `fpl doctor --providers` reads the season off the bootstrap its FPL probe already fetched rather than downloading it twice. is_previous_season_year() is the ordered counterpart the staleness checks compare a timestamp's cutover year with: during an overrun a July timestamp lands a year *ahead* of the season it was written in, so exact comparison would call live data a previous season's (#308); league-fines, which reads the ledger with no network, instead defaults to services/league_history.py's most_recent_captured_season() so its default agrees with whatever league-recap actually wrote
|
|
606
606
|
├── constants.py # MIN_MINUTES_FOR_PER90
|
|
607
607
|
└── utils/
|
|
608
608
|
├── gameweek.py # is_opening_gameweek(gw) — shared GW1 check (transfers, waivers and league tables don't exist yet)
|
|
@@ -96,6 +96,15 @@ about: the block is read on nearly every command, so the envelope naming it can
|
|
|
96
96
|
from `fpl status` or `fpl league-recap` as readily as from `fpl league-fines`, and the
|
|
97
97
|
message names the rule and the valid set.
|
|
98
98
|
|
|
99
|
+
A relative `FPL_CLI_CONFIG_DIR` / `FPL_CLI_DATA_DIR` / `FPL_CLI_CACHE_DIR` override is
|
|
100
|
+
rejected before any command runs, so this one case has no parsed `--format` to read when it
|
|
101
|
+
fails — the CLI scans the raw command line for `--format json` instead. The envelope's
|
|
102
|
+
`command` names your subcommand where that is unambiguous (`fpl status`, or a bare group like
|
|
103
|
+
`fpl chips`), and falls back to `"fpl"` for a subgroup's own subcommand (`fpl chips timing`,
|
|
104
|
+
`fpl squad grid`, `fpl intel show`) or a line that was nothing but options — those commands
|
|
105
|
+
don't name their own envelope after either word you typed, and guessing wrong would be worse
|
|
106
|
+
than saying so.
|
|
107
|
+
|
|
99
108
|
**Table mode splits the same way.** Without `--format json` the output you asked for goes
|
|
100
109
|
to stdout and everything else goes to stderr — warnings, progress notices, and the reason
|
|
101
110
|
a command exited nonzero. `fpl squad grid 2>/dev/null` prints a grid or prints nothing;
|
|
@@ -556,7 +565,7 @@ These metrics appear in `fpl player`, `fpl xg`, and gameweek reports. If Underst
|
|
|
556
565
|
|
|
557
566
|
**When a whole club fails to join.** FPL and Understat spell some clubs differently, so a `TEAM_NAME_MAP` gap costs one club's entire squad its npxG, xGChain and every score built on them, while the other 19 look fine. The commands that enrich from the live league payload — `fpl stats --value`, `fpl player`, `fpl allocate`, `fpl xg`, `fpl targets`, `fpl differentials` — report it once per club: on stderr in table mode, and as an `understat_team_unmatched` entry in JSON `metadata.warnings`, one per club, naming both the FPL club and the Understat name it was mapped to. `fpl doctor --providers` checks the same thing ahead of time. A club missing from a *past* season's payload is not this: `fpl returnees` scores a player's current club against the season they played, so the three clubs promoted since are absent from it every year by definition, and that stays a debug line rather than a warning about a map that is fine.
|
|
558
567
|
|
|
559
|
-
**When one player fails to join.** A player whose club Understat *does* carry can still fail to match on name, and that is not always a defect: Understat has not ingested the gameweek in progress,
|
|
568
|
+
**When one player fails to join.** A player whose club Understat *does* carry can still fail to match on name, and that is not always a defect: Understat has not ingested the gameweek in progress, names the player outright differently (FPL's `A.Becker` is Understat's `Alisson`), or simply has no row for them. Those read as `null` quality and value scores rather than as zeros, so the player is ranked as "no data" rather than as bad. A name the two sources merely *spell* differently does join, through a last-resort pass that stays inside the player's club and needs their minutes to agree and no second candidate: a transliteration variant (`Yarmoliuk` and `Yehor Yarmolyuk`), a double-barrelled or middle name one source drops (`Gannon-Doak` and `Ben Doak`, `Walle Egeli` and `Sindre Egeli`), or a mononym inside a longer name (`Jair Cunha` and `Jair`). A shared word alone is never enough — `João Pedro` and `Pedro Neto` share one — so a player whose row is genuinely missing stays `null` rather than wearing a club-mate's numbers. A few percent of a club's players missing this way is normal and stays a debug line naming each one with a running rate (`No Understat row matched ... — 3 of 351 FPL players with minutes unmatched so far this run`); the rate is what to watch, since a jump in it means the two sources have stopped agreeing about names rather than that one more cameo went un-ingested. `fpl doctor --providers` reports the same rate as an `Understat name join` row, which is where to look for it without turning on debug logging. Understat serves apostrophes HTML-escaped (`O'Shea`), which used to fail every apostrophe name silently; the payload is now decoded when it is fetched.
|
|
560
569
|
|
|
561
570
|
#### Quality and Value Scores
|
|
562
571
|
|
|
@@ -769,13 +778,17 @@ on a run with no fixture data, is reported alongside the completeness findings b
|
|
|
769
778
|
stderr, and as a warning callout in the saved report.
|
|
770
779
|
|
|
771
780
|
**Incomplete summaries:** the personal analysis is checked against the sections its own prompt
|
|
772
|
-
asked for before anything is written. A response that is missing a `## `
|
|
773
|
-
terminal punctuation, or that the provider reports it stopped early on is
|
|
774
|
-
if the second attempt is no better, the
|
|
775
|
-
what is missing. Under `--debug` the detail lands in
|
|
776
|
-
alongside `research_corrections.txt`. A saved report
|
|
777
|
-
same list as a warning callout above the summary, so
|
|
778
|
-
|
|
781
|
+
asked for before anything is written. A response that comes back empty, is missing a `## `
|
|
782
|
+
heading, ends without terminal punctuation, or that the provider reports it stopped early on is
|
|
783
|
+
retried **once** — and if the second attempt is no better, the less damaged of the two stands
|
|
784
|
+
and the run says so on stderr, naming what is missing. Under `--debug` the detail lands in
|
|
785
|
+
`data/debug/synthesis_corrections.txt` alongside `research_corrections.txt`. A saved report
|
|
786
|
+
generated from such a response carries the same list as a warning callout above the summary, so
|
|
787
|
+
a verdict the model dropped never reads as one deliberately omitted. The callout does not depend
|
|
788
|
+
on there being a summary to qualify: a response that returned no text at all — the shape a hard
|
|
789
|
+
`max_tokens` truncation takes when the model was still thinking at the ceiling — is the one that
|
|
790
|
+
most needs explaining, and is reported as a single "the response is empty" finding rather than
|
|
791
|
+
as every section it happens to be short of. A verdict for a format the run has no squad data for is not counted as
|
|
779
792
|
missing — the prompt tells the model to analyse only the format it was given.
|
|
780
793
|
|
|
781
794
|
**Blanks and doubles:** a zero from a player whose club had no fixture is marked `[BGW]` rather
|
|
@@ -1291,6 +1304,8 @@ Rolling a setup into a new season silently invalidates IDs and per-team files: a
|
|
|
1291
1304
|
|
|
1292
1305
|
**Environment** — which directory each of config/data/cache resolved to and whether an `FPL_CLI_*` override is in effect, plus whether `settings.yaml` exists.
|
|
1293
1306
|
|
|
1307
|
+
**Which season it is** — the season doctor reports in `metadata.season`, and judges "a previous season's" against, comes from GW1's `deadline_time` where the API answers and the July cutover only where it does not — the same rule `fpl status` uses, so the two commands cannot name different seasons for the same moment. A season that overruns 1 July (2019-20, COVID-delayed) therefore keeps its own label in both, and a `team_finances.json` scraped during that overrun reads as current rather than as a previous season's.
|
|
1308
|
+
|
|
1294
1309
|
Each finding is classified as **broken** (wrong answers now — fix it today), **stale** (self-corrects or needs one routine refresh), **skipped** (not configured/present), or **unchecked** (API unreachable). Exits non-zero when anything is broken, so it can gate scripts.
|
|
1295
1310
|
|
|
1296
1311
|
**`--providers`** checks the external data sources instead of the local setup. None of them version anything, and the tool degrades gracefully everywhere, so upstream drift otherwise surfaces as plausible but wrong output — a renamed stat field zeroes every player's xG without an error. Each probe asserts shape and volume, not just reachability, and where a column check is not the contract it runs the parser itself:
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.6.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 6, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from collections.abc import Sequence
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
@@ -97,3 +98,27 @@ def log_abnormal_stop(response: LLMResponse, label: str) -> None:
|
|
|
97
98
|
"the response may be cut off",
|
|
98
99
|
label, response.stop_reason, response.usage.output_tokens,
|
|
99
100
|
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def log_textless_response(
|
|
104
|
+
response: LLMResponse, label: str, non_text_blocks: Sequence[str],
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Announce a response whose content was blocks, none of them text.
|
|
107
|
+
|
|
108
|
+
A model that reasons before it answers emits its thinking first, so a
|
|
109
|
+
ceiling reached mid-thought returns content with no prose block in it at
|
|
110
|
+
all. The concatenated text is then `""` -- honest, but indistinguishable
|
|
111
|
+
downstream from a model that had nothing to say. Naming the blocks that
|
|
112
|
+
did arrive is what separates the two for anyone reading stderr (#306).
|
|
113
|
+
|
|
114
|
+
`non_text_blocks` is the types of the blocks that were *not* text, so an
|
|
115
|
+
answer of nothing but an empty text block stays silent: that is a model
|
|
116
|
+
with nothing to say, which is the case this has nothing to add to.
|
|
117
|
+
`log_abnormal_stop` has already spoken if it was cut off.
|
|
118
|
+
"""
|
|
119
|
+
if response.content or not non_text_blocks:
|
|
120
|
+
return
|
|
121
|
+
logger.warning(
|
|
122
|
+
"%s returned no text content -- the response carried only %s block(s)",
|
|
123
|
+
label, ", ".join(sorted(set(non_text_blocks))),
|
|
124
|
+
)
|
|
@@ -8,7 +8,13 @@ from typing import Any, ClassVar, Self
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
10
10
|
from fpl_cli.api.providers._http import RetryPolicy, post_json_with_retry
|
|
11
|
-
from fpl_cli.api.providers._models import
|
|
11
|
+
from fpl_cli.api.providers._models import (
|
|
12
|
+
LLMResponse,
|
|
13
|
+
ProviderError,
|
|
14
|
+
TokenUsage,
|
|
15
|
+
log_abnormal_stop,
|
|
16
|
+
log_textless_response,
|
|
17
|
+
)
|
|
12
18
|
|
|
13
19
|
_BASE_URL = "https://api.anthropic.com/v1"
|
|
14
20
|
_PROVIDER_LABEL = "Anthropic"
|
|
@@ -81,10 +87,21 @@ class AnthropicProvider:
|
|
|
81
87
|
label=_PROVIDER_LABEL, policy=self.RETRY_POLICY,
|
|
82
88
|
)
|
|
83
89
|
|
|
90
|
+
# Only text blocks carry prose, but the types that were *not* text are
|
|
91
|
+
# worth keeping: a ceiling reached while the model was still thinking
|
|
92
|
+
# returns a lone `thinking` block, and the empty string that falls out
|
|
93
|
+
# of this loop is otherwise indistinguishable from a model with
|
|
94
|
+
# nothing to say (#306). Text blocks are deliberately not collected --
|
|
95
|
+
# a response of nothing but an empty text block *is* a model with
|
|
96
|
+
# nothing to say, and naming "text" as the reason there was no text
|
|
97
|
+
# would contradict itself.
|
|
84
98
|
content = ""
|
|
99
|
+
non_text_blocks: list[str] = []
|
|
85
100
|
for block in data.get("content", []):
|
|
86
101
|
if block.get("type") == "text":
|
|
87
102
|
content += block.get("text", "")
|
|
103
|
+
else:
|
|
104
|
+
non_text_blocks.append(str(block.get("type")))
|
|
88
105
|
|
|
89
106
|
# Normalise usage: Anthropic returns input_tokens/output_tokens directly
|
|
90
107
|
raw_usage = data.get("usage", {})
|
|
@@ -105,6 +122,7 @@ class AnthropicProvider:
|
|
|
105
122
|
stop_reason=data.get("stop_reason") or None,
|
|
106
123
|
)
|
|
107
124
|
log_abnormal_stop(response, _PROVIDER_LABEL)
|
|
125
|
+
log_textless_response(response, _PROVIDER_LABEL, non_text_blocks)
|
|
108
126
|
return response
|
|
109
127
|
|
|
110
128
|
def post_process(self, content: str) -> str:
|
|
@@ -491,6 +491,31 @@ def _normalise(text: str) -> str:
|
|
|
491
491
|
return re.sub(r" +", " ", text).strip() # Collapse whitespace
|
|
492
492
|
|
|
493
493
|
|
|
494
|
+
# A consonant written twice, once `y` and `j` have already folded to `i`.
|
|
495
|
+
_DOUBLED_CONSONANT_RE = re.compile(r"([b-df-hk-np-tv-xz])\1")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@lru_cache(maxsize=4096)
|
|
499
|
+
def _fold_transliteration(name_norm: str) -> str:
|
|
500
|
+
"""Collapse the spellings a Latin transliteration commonly varies between.
|
|
501
|
+
|
|
502
|
+
Applied to an already-normalised name on *both* sides of a comparison, so
|
|
503
|
+
a pair that differs only by the fold compares equal: ``yarmoliuk`` and
|
|
504
|
+
``yarmolyuk`` both fold to ``iarmoliuk`` (#310). The folds are #310's
|
|
505
|
+
list of the variants a Ukrainian transliteration commonly turns on --
|
|
506
|
+
``y``/``i`` (``j`` too, which also covers ``ye`` against ``ie``),
|
|
507
|
+
``kh``/``h``, and a doubled consonant -- of which only ``y``/``i`` has
|
|
508
|
+
caused a miss so far. Each coarsens both names rather than guessing at
|
|
509
|
+
the right spelling of either, so the result is a blunter key, never a
|
|
510
|
+
corrected name; a Western name the fold also touches ("Markham" to
|
|
511
|
+
"marham") can only meet a same-club row whose fold is the same, with
|
|
512
|
+
minutes agreeing, after every strict tier has failed both.
|
|
513
|
+
"""
|
|
514
|
+
folded = name_norm.replace("kh", "h")
|
|
515
|
+
folded = re.sub(r"[yj]", "i", folded)
|
|
516
|
+
return _DOUBLED_CONSONANT_RE.sub(r"\1", folded)
|
|
517
|
+
|
|
518
|
+
|
|
494
519
|
def split_team_titles(team_title: str) -> list[str]:
|
|
495
520
|
"""Split an Understat ``team_title`` into its constituent club names.
|
|
496
521
|
|
|
@@ -538,15 +563,32 @@ _NAME_EXACT = 10
|
|
|
538
563
|
_NAME_ALL_WORDS = 8
|
|
539
564
|
_NAME_PREFIX = 7
|
|
540
565
|
|
|
541
|
-
# The
|
|
542
|
-
#
|
|
543
|
-
#
|
|
566
|
+
# The loose tiers (#310). Every tier above needs each FPL word found in the
|
|
567
|
+
# Understat name, and that asymmetry is what keeps "João Pedro" off "Pedro
|
|
568
|
+
# Neto" when the two share a club: a word FPL carries that Understat does not
|
|
569
|
+
# usually means a different person. Usually. It also fails a player the two
|
|
570
|
+
# sources simply name differently -- FPL's "Gannon-Doak" is Understat's "Ben
|
|
571
|
+
# Doak", "Walle Egeli" its "Sindre Egeli", "Jair Cunha" its mononym "Jair" --
|
|
572
|
+
# and a transliteration they spell differently ("Yarmoliuk" against "Yehor
|
|
573
|
+
# Yarmolyuk", "Yeremy" against "Yeremi Pino"), which no prefix survives
|
|
574
|
+
# because the divergence is mid-word. These sit below the floor, so neither
|
|
575
|
+
# the club pass nor the club-blind pass admits them; only the last-resort
|
|
576
|
+
# `_match_loosely_within_club` does, and it demands the club, the minutes and
|
|
577
|
+
# an unambiguous answer -- more corroboration than the strict tiers ask for.
|
|
578
|
+
# A shared word alone is not a tier: "Van Hecke" and "van de Ven" share one.
|
|
579
|
+
_NAME_TRANSLITERATED = 5 # exact or all-words once transliteration variants fold
|
|
580
|
+
_NAME_PARTIAL = 4 # surname agrees, or Understat's shorter name sits inside FPL's
|
|
581
|
+
|
|
582
|
+
# The lowest tier the club-gated pass will admit: the strict tiers, and only
|
|
583
|
+
# them. The loose floor is what the last-resort pass admits instead.
|
|
544
584
|
_NAME_TIER_FLOOR = _NAME_PREFIX
|
|
585
|
+
_NAME_LOOSE_FLOOR = _NAME_PARTIAL
|
|
545
586
|
|
|
546
|
-
# How closely the two sources must agree on minutes before a
|
|
547
|
-
#
|
|
548
|
-
#
|
|
549
|
-
|
|
587
|
+
# How closely the two sources must agree on minutes before a match missing one
|
|
588
|
+
# of its usual corroborations -- the club, or the full name -- is allowed to
|
|
589
|
+
# stand. Both count the same league's minutes, so a real match agrees closely
|
|
590
|
+
# and a namesake usually does not.
|
|
591
|
+
_CORROBORATING_MIN_MINUTES_RATIO = 0.5
|
|
550
592
|
|
|
551
593
|
# FPL teams already reported as matching no Understat players, so the join-drop
|
|
552
594
|
# warning below fires once per team per process rather than once per player.
|
|
@@ -742,6 +784,68 @@ def _minutes_ratio(fpl_minutes: int | None, player: dict[str, Any]) -> float | N
|
|
|
742
784
|
return min(fpl_minutes, us_minutes) / high
|
|
743
785
|
|
|
744
786
|
|
|
787
|
+
def _initials_agree(fpl_words: list[str], us_words: list[str]) -> bool:
|
|
788
|
+
"""Whether every one-letter FPL word is the initial of a word Understat spells out.
|
|
789
|
+
|
|
790
|
+
FPL abbreviates a first name to its initial exactly when a surname is
|
|
791
|
+
shared -- ``J.Ramsey`` beside ``A.Ramsey`` -- so the initial is FPL's
|
|
792
|
+
own statement of which one this is, and a loose tier that ignored it
|
|
793
|
+
would join ``J.Ramsey`` to ``Aaron Ramsey`` the week Jacob's row is
|
|
794
|
+
missing. A full word absent from Understat is a name it dropped; an
|
|
795
|
+
initial it contradicts is a different player.
|
|
796
|
+
"""
|
|
797
|
+
return all(
|
|
798
|
+
len(word) > 1 or any(us_word.startswith(word) for us_word in us_words)
|
|
799
|
+
for word in fpl_words
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _name_tier(fpl_name_norm: str, fpl_words: list[str], understat_name: str) -> int:
|
|
804
|
+
"""The confidence tier two normalised names match at, or 0 for none.
|
|
805
|
+
|
|
806
|
+
The three strict tiers first, then the loose ones (#310), which a caller
|
|
807
|
+
keeps out of its pass with a ``min_name_tier`` above them rather than by
|
|
808
|
+
asking for them not to be computed -- a tier is a fact about the two
|
|
809
|
+
names, and which tiers a pass trusts is that pass's decision.
|
|
810
|
+
"""
|
|
811
|
+
us_words = understat_name.split()
|
|
812
|
+
if fpl_name_norm == understat_name:
|
|
813
|
+
return _NAME_EXACT
|
|
814
|
+
if not fpl_words:
|
|
815
|
+
return 0
|
|
816
|
+
if all(w in us_words for w in fpl_words):
|
|
817
|
+
return _NAME_ALL_WORDS
|
|
818
|
+
if all(any(uw.startswith(fw) for uw in us_words) for fw in fpl_words):
|
|
819
|
+
return _NAME_PREFIX
|
|
820
|
+
|
|
821
|
+
# Transliteration: the same two full-name tests, on both names folded.
|
|
822
|
+
folded_fpl = _fold_transliteration(fpl_name_norm)
|
|
823
|
+
folded_us = _fold_transliteration(understat_name)
|
|
824
|
+
folded_us_words = folded_us.split()
|
|
825
|
+
if folded_fpl == folded_us or all(w in folded_us_words for w in folded_fpl.split()):
|
|
826
|
+
return _NAME_TRANSLITERATED
|
|
827
|
+
|
|
828
|
+
# Partial: both sides carry a surname and agree on it ("Gannon-Doak" and
|
|
829
|
+
# "Ben Doak"), or Understat's whole, shorter name sits inside FPL's ("Jair"
|
|
830
|
+
# in "Jair Cunha") -- the direction all-words does not cover. Both shapes
|
|
831
|
+
# share the guards: the words the two names share must be words, since
|
|
832
|
+
# `_normalise` keeps digits and a shared number is a shared row index,
|
|
833
|
+
# not a shared name; and neither may override an initial FPL chose to
|
|
834
|
+
# disambiguate with, which a bare surname can never satisfy -- FPL
|
|
835
|
+
# abbreviates to an initial exactly when the surname alone is ambiguous.
|
|
836
|
+
if (
|
|
837
|
+
len(fpl_words) > 1
|
|
838
|
+
and all(w.isalpha() for w in us_words)
|
|
839
|
+
and _initials_agree(fpl_words, us_words)
|
|
840
|
+
and (
|
|
841
|
+
(len(us_words) > 1 and fpl_words[-1] == us_words[-1])
|
|
842
|
+
or all(w in fpl_words for w in us_words)
|
|
843
|
+
)
|
|
844
|
+
):
|
|
845
|
+
return _NAME_PARTIAL
|
|
846
|
+
return 0
|
|
847
|
+
|
|
848
|
+
|
|
745
849
|
def _score_candidate(
|
|
746
850
|
player: dict[str, Any],
|
|
747
851
|
fpl_name_norm: str,
|
|
@@ -752,33 +856,21 @@ def _score_candidate(
|
|
|
752
856
|
) -> tuple[int, int]:
|
|
753
857
|
"""Score one Understat candidate as ``(name_tier, bonus)``.
|
|
754
858
|
|
|
755
|
-
The pair is ordered lexicographically by
|
|
859
|
+
The pair is ordered lexicographically by every caller, which keeps the
|
|
756
860
|
position and minutes bonuses as tiebreakers *within* a name tier instead of
|
|
757
861
|
letting them promote a looser name match above a stronger one.
|
|
758
862
|
|
|
759
863
|
A tier of 0 means "not a candidate": no viable name match, or one below
|
|
760
|
-
``min_name_tier``. Every field is read defensively — the
|
|
864
|
+
``min_name_tier``. Every field is read defensively — the club-blind pass
|
|
761
865
|
scores rows belonging to clubs the caller never asked about, so one
|
|
762
866
|
malformed row in an undocumented payload must not take out the lookup.
|
|
763
867
|
"""
|
|
764
868
|
understat_name = _normalise(str(player.get("name") or ""))
|
|
765
869
|
if not understat_name:
|
|
766
870
|
return 0, 0
|
|
767
|
-
us_words = understat_name.split()
|
|
768
|
-
|
|
769
|
-
# Word-overlap name scoring
|
|
770
|
-
if fpl_name_norm == understat_name:
|
|
771
|
-
tier = _NAME_EXACT
|
|
772
|
-
elif fpl_words and all(w in us_words for w in fpl_words):
|
|
773
|
-
tier = _NAME_ALL_WORDS
|
|
774
|
-
elif fpl_words and all(
|
|
775
|
-
any(uw.startswith(fw) for uw in us_words) for fw in fpl_words
|
|
776
|
-
):
|
|
777
|
-
tier = _NAME_PREFIX
|
|
778
|
-
else:
|
|
779
|
-
return 0, 0
|
|
780
871
|
|
|
781
|
-
|
|
872
|
+
tier = _name_tier(fpl_name_norm, fpl_words, understat_name)
|
|
873
|
+
if tier < min_name_tier: # 0 is below every floor
|
|
782
874
|
return 0, 0
|
|
783
875
|
|
|
784
876
|
bonus = 0
|
|
@@ -803,38 +895,30 @@ def _score_candidate(
|
|
|
803
895
|
return tier, bonus
|
|
804
896
|
|
|
805
897
|
|
|
806
|
-
def
|
|
898
|
+
def _best_corroborated_match(
|
|
899
|
+
candidates: list[dict[str, Any]],
|
|
807
900
|
fpl_name_norm: str,
|
|
808
901
|
fpl_words: list[str],
|
|
809
|
-
understat_players: list[dict[str, Any]],
|
|
810
902
|
fpl_position: str | None,
|
|
811
903
|
fpl_minutes: int | None,
|
|
904
|
+
min_name_tier: int,
|
|
905
|
+
pass_name: str,
|
|
812
906
|
) -> dict[str, Any] | None:
|
|
813
|
-
"""
|
|
907
|
+
"""The one candidate a pass missing its usual corroboration can stand on.
|
|
814
908
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
people are deciding whether to buy.
|
|
821
|
-
|
|
822
|
-
Dropping the gate re-opens the homonym risk it existed to close, so this
|
|
823
|
-
pass is deliberately narrow. Only a full-name match counts (exact, or every
|
|
824
|
-
FPL word present — no prefix tier). Minutes must corroborate where both
|
|
825
|
-
sources report them, since a namesake at another club rarely has a season
|
|
826
|
-
the same length. A top pair two candidates share is refused rather than
|
|
827
|
-
guessed at. The caller adds the last condition: it only reaches here for a
|
|
828
|
-
club Understat *does* carry players for, so an unresolved club still fails
|
|
829
|
-
as a block rather than 20 players each guessing across the league.
|
|
909
|
+
Shared by the two fallback passes, which each relax a different thing --
|
|
910
|
+
the club-blind pass the club, the loose pass the name -- and so lean on
|
|
911
|
+
the same substitutes: minutes must corroborate where both sources report
|
|
912
|
+
them, since a namesake rarely has a season the same length, and a top
|
|
913
|
+
score two candidates share is refused rather than guessed at.
|
|
830
914
|
"""
|
|
831
915
|
best_match: dict[str, Any] | None = None
|
|
832
916
|
best_score = (0, 0)
|
|
833
917
|
ambiguous = False
|
|
834
918
|
|
|
835
|
-
for player in
|
|
919
|
+
for player in candidates:
|
|
836
920
|
ratio = _minutes_ratio(fpl_minutes, player)
|
|
837
|
-
if ratio is not None and ratio <
|
|
921
|
+
if ratio is not None and ratio < _CORROBORATING_MIN_MINUTES_RATIO:
|
|
838
922
|
continue
|
|
839
923
|
score = _score_candidate(
|
|
840
924
|
player,
|
|
@@ -842,7 +926,7 @@ def _match_across_clubs(
|
|
|
842
926
|
fpl_words,
|
|
843
927
|
fpl_position,
|
|
844
928
|
fpl_minutes,
|
|
845
|
-
min_name_tier=
|
|
929
|
+
min_name_tier=min_name_tier,
|
|
846
930
|
)
|
|
847
931
|
if score[0] == 0:
|
|
848
932
|
continue
|
|
@@ -853,15 +937,86 @@ def _match_across_clubs(
|
|
|
853
937
|
|
|
854
938
|
if ambiguous:
|
|
855
939
|
logger.debug(
|
|
856
|
-
"Understat name %r
|
|
940
|
+
"Understat name %r has several equally good %s candidates — "
|
|
857
941
|
"declining rather than guessing",
|
|
858
942
|
fpl_name_norm,
|
|
943
|
+
pass_name,
|
|
859
944
|
)
|
|
860
945
|
return None
|
|
861
946
|
|
|
862
947
|
return best_match
|
|
863
948
|
|
|
864
949
|
|
|
950
|
+
def _match_across_clubs(
|
|
951
|
+
fpl_name_norm: str,
|
|
952
|
+
fpl_words: list[str],
|
|
953
|
+
understat_players: list[dict[str, Any]],
|
|
954
|
+
fpl_position: str | None,
|
|
955
|
+
fpl_minutes: int | None,
|
|
956
|
+
) -> dict[str, Any] | None:
|
|
957
|
+
"""Match on name alone, for a player the club gate rejected outright (#234).
|
|
958
|
+
|
|
959
|
+
An Understat ``team_title`` names only the clubs a player has actually
|
|
960
|
+
turned out for, so a deadline-day mover who has not yet featured for their
|
|
961
|
+
new club carries the old club alone while FPL already lists them at the new
|
|
962
|
+
one. #151's comma-joined title never forms, every candidate fails the gate,
|
|
963
|
+
and they read as having no Understat data at all through exactly the weeks
|
|
964
|
+
people are deciding whether to buy.
|
|
965
|
+
|
|
966
|
+
Dropping the gate re-opens the homonym risk it existed to close, so this
|
|
967
|
+
pass is deliberately narrow. Only a full-name match counts (exact, or every
|
|
968
|
+
FPL word present — no prefix tier, and none of the loose ones). Minutes
|
|
969
|
+
must corroborate and a shared top score is refused, as
|
|
970
|
+
`_best_corroborated_match` describes. The caller adds the last condition:
|
|
971
|
+
it only reaches here for a club Understat *does* carry players for, so an
|
|
972
|
+
unresolved club still fails as a block rather than 20 players each
|
|
973
|
+
guessing across the league.
|
|
974
|
+
"""
|
|
975
|
+
return _best_corroborated_match(
|
|
976
|
+
understat_players,
|
|
977
|
+
fpl_name_norm,
|
|
978
|
+
fpl_words,
|
|
979
|
+
fpl_position,
|
|
980
|
+
fpl_minutes,
|
|
981
|
+
min_name_tier=_NAME_ALL_WORDS,
|
|
982
|
+
pass_name="club-blind",
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _match_loosely_within_club(
|
|
987
|
+
fpl_name_norm: str,
|
|
988
|
+
fpl_words: list[str],
|
|
989
|
+
club_rows: list[dict[str, Any]],
|
|
990
|
+
fpl_position: str | None,
|
|
991
|
+
fpl_minutes: int | None,
|
|
992
|
+
) -> dict[str, Any] | None:
|
|
993
|
+
"""Match a player the two sources name differently, inside their club (#310).
|
|
994
|
+
|
|
995
|
+
The last resort, and ordered last on purpose: it runs only once the strict
|
|
996
|
+
tiers have failed within the club *and* the full-name pass has failed
|
|
997
|
+
across it, so a mover whose Understat row still shows the old club under
|
|
998
|
+
the full name reaches the club-blind pass before a same-surname teammate
|
|
999
|
+
at the new club can claim them here.
|
|
1000
|
+
|
|
1001
|
+
It admits the tiers below the floor -- a name that agrees once
|
|
1002
|
+
transliteration variants fold, a shared surname beside a name one source
|
|
1003
|
+
omits, a mononym inside a longer name -- and never leaves the club, since
|
|
1004
|
+
across the league a shared surname is a namesake far more often than a
|
|
1005
|
+
spelling. Minutes must corroborate and a shared top score is refused, as
|
|
1006
|
+
`_best_corroborated_match` describes. The caller keeps it to the live
|
|
1007
|
+
pool, where the club a row carries is the club being asked about.
|
|
1008
|
+
"""
|
|
1009
|
+
return _best_corroborated_match(
|
|
1010
|
+
club_rows,
|
|
1011
|
+
fpl_name_norm,
|
|
1012
|
+
fpl_words,
|
|
1013
|
+
fpl_position,
|
|
1014
|
+
fpl_minutes,
|
|
1015
|
+
min_name_tier=_NAME_LOOSE_FLOOR,
|
|
1016
|
+
pass_name="loose same-club",
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
865
1020
|
def match_fpl_to_understat(
|
|
866
1021
|
fpl_name: str,
|
|
867
1022
|
fpl_team: str,
|
|
@@ -876,13 +1031,17 @@ def match_fpl_to_understat(
|
|
|
876
1031
|
position and minutes played, and returns the most confident. A player whose
|
|
877
1032
|
own club carries no name match at all falls through to the name-only pass
|
|
878
1033
|
in ``_match_across_clubs`` (#234) — but only when the club itself resolved,
|
|
879
|
-
so a club no Understat row carries keeps failing as a block
|
|
880
|
-
|
|
1034
|
+
so a club no Understat row carries keeps failing as a block — and, failing
|
|
1035
|
+
that too and only in the live pool, to ``_match_loosely_within_club``
|
|
1036
|
+
(#310), which admits the looser name tiers back inside the club. Returns
|
|
1037
|
+
None when no pass is confident.
|
|
881
1038
|
|
|
882
1039
|
*season_label* names the season ``understat_players`` covers when that is
|
|
883
1040
|
not the one in progress; leaving it None says the pool is the live one. It
|
|
884
|
-
|
|
885
|
-
|
|
1041
|
+
steers the join-drop tripwire — see ``_report_unmatched_team`` for why an
|
|
1042
|
+
absent club means something different in a past season's pool — and
|
|
1043
|
+
withholds the loose pass, whose club corroboration a past pool cannot
|
|
1044
|
+
give: the club matched there is the player's current one.
|
|
886
1045
|
"""
|
|
887
1046
|
fpl_name_norm = _normalise(fpl_name)
|
|
888
1047
|
fpl_words = fpl_name_norm.split()
|
|
@@ -890,18 +1049,14 @@ def match_fpl_to_understat(
|
|
|
890
1049
|
|
|
891
1050
|
best_match = None
|
|
892
1051
|
best_score = (0, 0)
|
|
893
|
-
team_seen = False
|
|
894
|
-
|
|
895
|
-
for player in understat_players:
|
|
896
|
-
# The same gate `understat_club_rows` (and so `fpl doctor`) applies, so
|
|
897
|
-
# the health check and the enrichment can never disagree about which
|
|
898
|
-
# clubs resolve. Season totals stay cumulative across both clubs of a
|
|
899
|
-
# mid-season move, which is what the minutes bonus wants — FPL's
|
|
900
|
-
# minutes are cumulative too.
|
|
901
|
-
if not _carries_club(player.get("team"), fpl_team_mapped):
|
|
902
|
-
continue
|
|
903
|
-
team_seen = True
|
|
904
1052
|
|
|
1053
|
+
# The gate `fpl doctor` probes through, so the health check and the
|
|
1054
|
+
# enrichment can never disagree about which clubs resolve. Season totals
|
|
1055
|
+
# stay cumulative across both clubs of a mid-season move, which is what
|
|
1056
|
+
# the minutes bonus wants — FPL's minutes are cumulative too.
|
|
1057
|
+
club_rows = understat_club_rows(fpl_team, understat_players)
|
|
1058
|
+
|
|
1059
|
+
for player in club_rows:
|
|
905
1060
|
score = _score_candidate(
|
|
906
1061
|
player,
|
|
907
1062
|
fpl_name_norm,
|
|
@@ -914,10 +1069,10 @@ def match_fpl_to_understat(
|
|
|
914
1069
|
best_score = score
|
|
915
1070
|
best_match = player
|
|
916
1071
|
|
|
917
|
-
if not
|
|
1072
|
+
if not club_rows and understat_players:
|
|
918
1073
|
_report_unmatched_team(fpl_team, fpl_team_mapped, season_label)
|
|
919
1074
|
|
|
920
|
-
if not
|
|
1075
|
+
if not club_rows:
|
|
921
1076
|
# Nothing carries this club, so every one of its players fails here
|
|
922
1077
|
# identically — a TEAM_NAME_MAP gap or a roster Understat has yet to
|
|
923
1078
|
# ingest, not a transfer. Sending 20 players off to guess across the
|
|
@@ -933,5 +1088,16 @@ def match_fpl_to_understat(
|
|
|
933
1088
|
fpl_name_norm, fpl_words, understat_players, fpl_position, fpl_minutes
|
|
934
1089
|
)
|
|
935
1090
|
|
|
1091
|
+
if best_match is None and season_label is None:
|
|
1092
|
+
# Neither the strict tiers inside the club nor the full name across
|
|
1093
|
+
# it: the two sources may simply spell this player differently. Live
|
|
1094
|
+
# pool only. A past season's pool is matched against the player's
|
|
1095
|
+
# *current* club, and for the mover that path calls its common case
|
|
1096
|
+
# the club is the one corroboration the loose tiers lean on -- they
|
|
1097
|
+
# would be scanning the current club's old roster for a surname twin.
|
|
1098
|
+
best_match = _match_loosely_within_club(
|
|
1099
|
+
fpl_name_norm, fpl_words, club_rows, fpl_position, fpl_minutes
|
|
1100
|
+
)
|
|
1101
|
+
|
|
936
1102
|
_record_name_join(fpl_name, fpl_team, fpl_minutes, best_match, season_label)
|
|
937
1103
|
return best_match
|