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.
Files changed (315) hide show
  1. {fplkit-2.5.0 → fplkit-2.6.0}/CHANGELOG.md +37 -0
  2. {fplkit-2.5.0 → fplkit-2.6.0}/PKG-INFO +1 -1
  3. {fplkit-2.5.0 → fplkit-2.6.0}/docs/architecture.md +3 -3
  4. {fplkit-2.5.0 → fplkit-2.6.0}/docs/command-reference.md +23 -8
  5. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/_version.py +2 -2
  6. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/_models.py +25 -0
  7. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/anthropic.py +19 -1
  8. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/understat.py +228 -62
  9. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_context.py +80 -2
  10. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/doctor.py +64 -24
  11. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/doctor_providers.py +11 -3
  12. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/ratings.py +1 -1
  13. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/review.py +35 -12
  14. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/season.py +24 -0
  15. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/returnee_radar.py +7 -3
  16. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/templates/gw_review.md.j2 +7 -2
  17. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_understat.py +235 -0
  18. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_doctor.py +101 -9
  19. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_doctor_providers.py +15 -0
  20. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_format.py +85 -1
  21. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_review.py +39 -0
  22. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_paths.py +88 -0
  23. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_providers.py +91 -1
  24. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_report_agent.py +59 -0
  25. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_returnee_radar.py +41 -0
  26. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_review.py +43 -1
  27. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_season.py +21 -0
  28. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_team_ratings.py +3 -0
  29. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/README.md +0 -0
  30. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/TOOLS.md +0 -0
  31. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/build-test-plan/SKILL.md +0 -0
  32. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
  33. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
  34. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/create-pr/SKILL.md +0 -0
  35. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/SKILL.md +0 -0
  36. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
  37. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/references/output-template.md +0 -0
  38. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/references/rules.md +0 -0
  39. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
  40. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
  41. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
  42. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
  43. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
  44. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
  45. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
  46. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/preview-ingest/SKILL.md +0 -0
  47. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/release/SKILL.md +0 -0
  48. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/release-notes/SKILL.md +0 -0
  49. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/squad-builder/SKILL.md +0 -0
  50. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/squad-builder/references/output-template.md +0 -0
  51. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/squad-builder/references/rules.md +0 -0
  52. {fplkit-2.5.0 → fplkit-2.6.0}/.agents/skills/update-gw-prep/SKILL.md +0 -0
  53. {fplkit-2.5.0 → fplkit-2.6.0}/.claude/output-styles/fpl-mate.md +0 -0
  54. {fplkit-2.5.0 → fplkit-2.6.0}/.claude/settings.json +0 -0
  55. {fplkit-2.5.0 → fplkit-2.6.0}/.env.example +0 -0
  56. {fplkit-2.5.0 → fplkit-2.6.0}/.github/workflows/ci.yml +0 -0
  57. {fplkit-2.5.0 → fplkit-2.6.0}/.github/workflows/pr-title.yml +0 -0
  58. {fplkit-2.5.0 → fplkit-2.6.0}/.github/workflows/release.yml +0 -0
  59. {fplkit-2.5.0 → fplkit-2.6.0}/.gitignore +0 -0
  60. {fplkit-2.5.0 → fplkit-2.6.0}/AGENTS.md +0 -0
  61. {fplkit-2.5.0 → fplkit-2.6.0}/CLAUDE.md +0 -0
  62. {fplkit-2.5.0 → fplkit-2.6.0}/CONTRIBUTING.md +0 -0
  63. {fplkit-2.5.0 → fplkit-2.6.0}/LICENSE +0 -0
  64. {fplkit-2.5.0 → fplkit-2.6.0}/README.md +0 -0
  65. {fplkit-2.5.0 → fplkit-2.6.0}/cliff.toml +0 -0
  66. {fplkit-2.5.0 → fplkit-2.6.0}/config/team_ratings_overrides.yaml +0 -0
  67. {fplkit-2.5.0 → fplkit-2.6.0}/docs/custom-analysis.md +0 -0
  68. {fplkit-2.5.0 → fplkit-2.6.0}/docs/fpl-rules.md +0 -0
  69. {fplkit-2.5.0 → fplkit-2.6.0}/docs/images/fpl-player-demo-v1-0.png +0 -0
  70. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/__init__.py +0 -0
  71. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/__init__.py +0 -0
  72. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/action/__init__.py +0 -0
  73. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/action/waiver.py +0 -0
  74. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/__init__.py +0 -0
  75. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/bench_order.py +0 -0
  76. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/captain.py +0 -0
  77. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
  78. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/starting_xi.py +0 -0
  79. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/stats.py +0 -0
  80. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
  81. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/base.py +0 -0
  82. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/common.py +0 -0
  83. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/__init__.py +0 -0
  84. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/fixture.py +0 -0
  85. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/price.py +0 -0
  86. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/data/scout.py +0 -0
  87. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/orchestration/__init__.py +0 -0
  88. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/agents/orchestration/report.py +0 -0
  89. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/__init__.py +0 -0
  90. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/contract.py +0 -0
  91. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/core_insights.py +0 -0
  92. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/dataset_fetcher.py +0 -0
  93. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/football_data.py +0 -0
  94. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/fpl.py +0 -0
  95. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/fpl_draft.py +0 -0
  96. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/historical.py +0 -0
  97. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/historical_types.py +0 -0
  98. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/__init__.py +0 -0
  99. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/_http.py +0 -0
  100. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/openai_compat.py +0 -0
  101. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/providers/perplexity.py +0 -0
  102. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/api/vaastav.py +0 -0
  103. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/__init__.py +0 -0
  104. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_banner.py +0 -0
  105. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_fines.py +0 -0
  106. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_fines_config.py +0 -0
  107. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_helpers.py +0 -0
  108. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_json.py +0 -0
  109. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_league_recap_data.py +0 -0
  110. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_league_recap_history.py +0 -0
  111. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_league_recap_types.py +0 -0
  112. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_plan_grid.py +0 -0
  113. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_analysis.py +0 -0
  114. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_classic.py +0 -0
  115. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_draft.py +0 -0
  116. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/_review_summarisation.py +0 -0
  117. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/allocate.py +0 -0
  118. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/captain.py +0 -0
  119. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/chips.py +0 -0
  120. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/credentials.py +0 -0
  121. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/differentials.py +0 -0
  122. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/fdr.py +0 -0
  123. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/fixtures.py +0 -0
  124. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/history.py +0 -0
  125. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/init.py +0 -0
  126. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/intel.py +0 -0
  127. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/league.py +0 -0
  128. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/league_fines.py +0 -0
  129. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/league_recap.py +0 -0
  130. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/player.py +0 -0
  131. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/preview.py +0 -0
  132. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/price_changes.py +0 -0
  133. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/price_history.py +0 -0
  134. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/returnees.py +0 -0
  135. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/review.py +0 -0
  136. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/sell_prices.py +0 -0
  137. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/squad.py +0 -0
  138. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/stats.py +0 -0
  139. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/status.py +0 -0
  140. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/targets.py +0 -0
  141. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/transfer_eval.py +0 -0
  142. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/waivers.py +0 -0
  143. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/cli/xg.py +0 -0
  144. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/defaults.yaml +0 -0
  145. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/fixture_predictions.yaml +0 -0
  146. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
  147. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/config/team_managers.yaml +0 -0
  148. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/constants.py +0 -0
  149. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/__init__.py +0 -0
  150. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/chip_plan.py +0 -0
  151. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/fixture.py +0 -0
  152. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/league_history.py +0 -0
  153. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/player.py +0 -0
  154. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/team.py +0 -0
  155. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/models/types.py +0 -0
  156. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/parsers/__init__.py +0 -0
  157. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/parsers/recommendations.py +0 -0
  158. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/paths.py +0 -0
  159. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/__init__.py +0 -0
  160. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/league_recap.py +0 -0
  161. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/returnees.py +0 -0
  162. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/prompts/scout.py +0 -0
  163. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/scraper/__init__.py +0 -0
  164. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/scraper/fpl_prices.py +0 -0
  165. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/__init__.py +0 -0
  166. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/fixture_predictions.py +0 -0
  167. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history.py +0 -0
  168. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history_counters.py +0 -0
  169. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history_fines.py +0 -0
  170. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/league_history_notes.py +0 -0
  171. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/matchup.py +0 -0
  172. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/player_clubs.py +0 -0
  173. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/player_prior.py +0 -0
  174. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/__init__.py +0 -0
  175. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/constants.py +0 -0
  176. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/data_prep.py +0 -0
  177. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/display.py +0 -0
  178. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/evaluation.py +0 -0
  179. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/ownership.py +0 -0
  180. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/shrinkage.py +0 -0
  181. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/signals.py +0 -0
  182. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/single_gw.py +0 -0
  183. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/scoring/value_quality.py +0 -0
  184. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/season_previews.py +0 -0
  185. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/squad_allocator.py +0 -0
  186. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/team_form.py +0 -0
  187. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/team_ratings.py +0 -0
  188. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/services/team_ratings_prior.py +0 -0
  189. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
  190. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/templates/gw_preview.md.j2 +0 -0
  191. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/__init__.py +0 -0
  192. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/files.py +0 -0
  193. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/gameweek.py +0 -0
  194. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/markdown.py +0 -0
  195. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/teams.py +0 -0
  196. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/text.py +0 -0
  197. {fplkit-2.5.0 → fplkit-2.6.0}/fpl_cli/utils/time.py +0 -0
  198. {fplkit-2.5.0 → fplkit-2.6.0}/pyproject.toml +0 -0
  199. {fplkit-2.5.0 → fplkit-2.6.0}/pyrightconfig.json +0 -0
  200. {fplkit-2.5.0 → fplkit-2.6.0}/scripts/backtest_early_season_prior_blend.py +0 -0
  201. {fplkit-2.5.0 → fplkit-2.6.0}/scripts/calibrate_promoted_prior.py +0 -0
  202. {fplkit-2.5.0 → fplkit-2.6.0}/scripts/calibrate_quality_ceilings.py +0 -0
  203. {fplkit-2.5.0 → fplkit-2.6.0}/tests/__init__.py +0 -0
  204. {fplkit-2.5.0 → fplkit-2.6.0}/tests/conftest.py +0 -0
  205. {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/classic_squad_fixture.md +0 -0
  206. {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/recommendations_fixture.md +0 -0
  207. {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
  208. {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
  209. {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
  210. {fplkit-2.5.0 → fplkit-2.6.0}/tests/fixtures/waiver_ranked_golden.json +0 -0
  211. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_action.py +0 -0
  212. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_analysis.py +0 -0
  213. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_analysis_squad.py +0 -0
  214. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_base.py +0 -0
  215. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_data.py +0 -0
  216. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_data_price.py +0 -0
  217. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_agents_orchestration.py +0 -0
  218. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_allocate.py +0 -0
  219. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_client.py +0 -0
  220. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_core_insights.py +0 -0
  221. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_dataset_fetcher.py +0 -0
  222. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_draft.py +0 -0
  223. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_football_data.py +0 -0
  224. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_historical.py +0 -0
  225. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_api_vaastav.py +0 -0
  226. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_bench_order_script.py +0 -0
  227. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_calibrate_quality_ceilings.py +0 -0
  228. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_classic_squad_extraction.py +0 -0
  229. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_allocate.py +0 -0
  230. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_banner.py +0 -0
  231. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_captain.py +0 -0
  232. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_chips.py +0 -0
  233. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_context_settings.py +0 -0
  234. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_credentials.py +0 -0
  235. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_custom_analysis.py +0 -0
  236. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_differentials.py +0 -0
  237. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_failure_parity.py +0 -0
  238. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_failure_streams.py +0 -0
  239. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fdr.py +0 -0
  240. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fdr_blanks.py +0 -0
  241. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fixtures.py +0 -0
  242. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_format_suppression.py +0 -0
  243. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_fpl_block.py +0 -0
  244. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_history.py +0 -0
  245. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_init.py +0 -0
  246. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_intel.py +0 -0
  247. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_json.py +0 -0
  248. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_json_contract.py +0 -0
  249. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_league.py +0 -0
  250. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_league_fines.py +0 -0
  251. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_league_recap.py +0 -0
  252. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_plan_grid.py +0 -0
  253. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_player.py +0 -0
  254. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_preview.py +0 -0
  255. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_price_changes.py +0 -0
  256. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_price_history.py +0 -0
  257. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_returnees.py +0 -0
  258. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_sell_prices.py +0 -0
  259. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_settings_warning.py +0 -0
  260. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_squad.py +0 -0
  261. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_stats.py +0 -0
  262. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_status.py +0 -0
  263. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_targets.py +0 -0
  264. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_transfer_eval.py +0 -0
  265. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_waivers.py +0 -0
  266. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_warnings_stderr.py +0 -0
  267. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_cli_xg.py +0 -0
  268. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_common.py +0 -0
  269. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_common_scoring.py +0 -0
  270. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_data_prep_harmonisation.py +0 -0
  271. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_dependencies.py +0 -0
  272. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_doc_consistency.py +0 -0
  273. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fines.py +0 -0
  274. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fines_config.py +0 -0
  275. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fixture_predictions.py +0 -0
  276. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_fpl_prices.py +0 -0
  277. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_gw_prep_script_bootstrap.py +0 -0
  278. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_init.py +0 -0
  279. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_init_fines.py +0 -0
  280. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_counters.py +0 -0
  281. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_fines.py +0 -0
  282. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_notes.py +0 -0
  283. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_history_store.py +0 -0
  284. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_league_recap.py +0 -0
  285. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_matchup_service.py +0 -0
  286. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_models.py +0 -0
  287. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_normalise_entities.py +0 -0
  288. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_output_style_sync.py +0 -0
  289. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_player_clubs.py +0 -0
  290. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_player_prior.py +0 -0
  291. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_player_resolution.py +0 -0
  292. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_preseason.py +0 -0
  293. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_prompt_tables.py +0 -0
  294. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_recommendations_parser.py +0 -0
  295. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_report_paths.py +0 -0
  296. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_rolling_pts_per_m.py +0 -0
  297. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_scoring.py +0 -0
  298. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_scraper.py +0 -0
  299. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_season_awareness.py +0 -0
  300. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_season_previews.py +0 -0
  301. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_sell_prices.py +0 -0
  302. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_skill_fdr_quality_signals.py +0 -0
  303. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_skill_stats_thresholds.py +0 -0
  304. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_squad_allocator.py +0 -0
  305. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_starting_xi_agent.py +0 -0
  306. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_starting_xi_script.py +0 -0
  307. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_stats.py +0 -0
  308. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_team_ratings_prior.py +0 -0
  309. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_transfer_eval_agent.py +0 -0
  310. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_transfer_eval_script.py +0 -0
  311. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_gameweek.py +0 -0
  312. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_markdown.py +0 -0
  313. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_text.py +0 -0
  314. {fplkit-2.5.0 → fplkit-2.6.0}/tests/test_utils_time.py +0 -0
  315. {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.5.0
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, spells a transliterated name differently, or simply has no row for the player. 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 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.
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 `## ` heading, ends without
773
- terminal punctuation, or that the provider reports it stopped early on is retried **once** — and
774
- if the second attempt is no better, the first one stands and the run says so on stderr, naming
775
- what is missing. Under `--debug` the detail lands in `data/debug/synthesis_corrections.txt`
776
- alongside `research_corrections.txt`. A saved report generated from such a response carries the
777
- same list as a warning callout above the summary, so a verdict the model dropped never reads as
778
- one deliberately omitted. A verdict for a format the run has no squad data for is not counted as
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.5.0'
22
- __version_tuple__ = version_tuple = (2, 5, 0)
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 LLMResponse, ProviderError, TokenUsage, log_abnormal_stop
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 lowest tier the club-gated pass will admit. It is every tier there is
542
- # today, and deliberately named rather than left implicit: a future
543
- # lower-confidence tier has to raise this floor to opt itself out.
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 club-blind match
547
- # is allowed to stand. Both count the same league's minutes, so a real match
548
- # agrees closely and a namesake at another club usually does not.
549
- _CROSS_CLUB_MIN_MINUTES_RATIO = 0.5
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 both callers, which keeps the
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 fallback pass
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
- if tier < min_name_tier:
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 _match_across_clubs(
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
- """Match on name alone, for a player the club gate rejected outright (#234).
907
+ """The one candidate a pass missing its usual corroboration can stand on.
814
908
 
815
- An Understat ``team_title`` names only the clubs a player has actually
816
- turned out for, so a deadline-day mover who has not yet featured for their
817
- new club carries the old club alone while FPL already lists them at the new
818
- one. #151's comma-joined title never forms, every candidate fails the gate,
819
- and they read as having no Understat data at all through exactly the weeks
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 understat_players:
919
+ for player in candidates:
836
920
  ratio = _minutes_ratio(fpl_minutes, player)
837
- if ratio is not None and ratio < _CROSS_CLUB_MIN_MINUTES_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=_NAME_ALL_WORDS,
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 matches several players outside the FPL club — "
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. Returns None
880
- when neither pass is confident.
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
- only steers the join-drop tripwire — see ``_report_unmatched_team`` for why
885
- an absent club means something different in a past season's pool.
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 team_seen and understat_players:
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 team_seen:
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