fplkit 2.7.3__tar.gz → 2.7.4__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 (318) hide show
  1. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/TOOLS.md +1 -1
  2. {fplkit-2.7.3 → fplkit-2.7.4}/CHANGELOG.md +15 -0
  3. {fplkit-2.7.3 → fplkit-2.7.4}/PKG-INFO +1 -1
  4. {fplkit-2.7.3 → fplkit-2.7.4}/docs/architecture.md +1 -1
  5. {fplkit-2.7.3 → fplkit-2.7.4}/docs/command-reference.md +16 -0
  6. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/_version.py +2 -2
  7. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_helpers.py +155 -1
  8. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_classic.py +23 -86
  9. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_draft.py +18 -59
  10. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/league.py +16 -79
  11. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/league_recap_checks.py +99 -64
  12. fplkit-2.7.4/tests/test_cli_league.py +376 -0
  13. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_review.py +142 -7
  14. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_recap.py +85 -6
  15. fplkit-2.7.3/tests/test_cli_league.py +0 -161
  16. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/README.md +0 -0
  17. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/build-test-plan/SKILL.md +0 -0
  18. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
  19. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
  20. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/create-pr/SKILL.md +0 -0
  21. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/SKILL.md +0 -0
  22. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
  23. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/references/output-template.md +0 -0
  24. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/references/rules.md +0 -0
  25. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
  26. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
  27. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
  28. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
  29. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
  30. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
  31. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
  32. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/preview-ingest/SKILL.md +0 -0
  33. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/release/SKILL.md +0 -0
  34. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/release-notes/SKILL.md +0 -0
  35. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/squad-builder/SKILL.md +0 -0
  36. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/squad-builder/references/output-template.md +0 -0
  37. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/squad-builder/references/rules.md +0 -0
  38. {fplkit-2.7.3 → fplkit-2.7.4}/.agents/skills/update-gw-prep/SKILL.md +0 -0
  39. {fplkit-2.7.3 → fplkit-2.7.4}/.claude/output-styles/fpl-mate.md +0 -0
  40. {fplkit-2.7.3 → fplkit-2.7.4}/.claude/settings.json +0 -0
  41. {fplkit-2.7.3 → fplkit-2.7.4}/.env.example +0 -0
  42. {fplkit-2.7.3 → fplkit-2.7.4}/.github/workflows/ci.yml +0 -0
  43. {fplkit-2.7.3 → fplkit-2.7.4}/.github/workflows/pr-title.yml +0 -0
  44. {fplkit-2.7.3 → fplkit-2.7.4}/.github/workflows/release.yml +0 -0
  45. {fplkit-2.7.3 → fplkit-2.7.4}/.gitignore +0 -0
  46. {fplkit-2.7.3 → fplkit-2.7.4}/AGENTS.md +0 -0
  47. {fplkit-2.7.3 → fplkit-2.7.4}/CLAUDE.md +0 -0
  48. {fplkit-2.7.3 → fplkit-2.7.4}/CONTRIBUTING.md +0 -0
  49. {fplkit-2.7.3 → fplkit-2.7.4}/LICENSE +0 -0
  50. {fplkit-2.7.3 → fplkit-2.7.4}/README.md +0 -0
  51. {fplkit-2.7.3 → fplkit-2.7.4}/cliff.toml +0 -0
  52. {fplkit-2.7.3 → fplkit-2.7.4}/config/team_ratings_overrides.yaml +0 -0
  53. {fplkit-2.7.3 → fplkit-2.7.4}/docs/custom-analysis.md +0 -0
  54. {fplkit-2.7.3 → fplkit-2.7.4}/docs/fpl-rules.md +0 -0
  55. {fplkit-2.7.3 → fplkit-2.7.4}/docs/images/fpl-player-demo-v1-0.png +0 -0
  56. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/__init__.py +0 -0
  57. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/__init__.py +0 -0
  58. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/action/__init__.py +0 -0
  59. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/action/waiver.py +0 -0
  60. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/__init__.py +0 -0
  61. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/bench_order.py +0 -0
  62. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/captain.py +0 -0
  63. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
  64. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/starting_xi.py +0 -0
  65. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/stats.py +0 -0
  66. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
  67. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/base.py +0 -0
  68. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/common.py +0 -0
  69. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/__init__.py +0 -0
  70. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/fixture.py +0 -0
  71. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/price.py +0 -0
  72. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/data/scout.py +0 -0
  73. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/orchestration/__init__.py +0 -0
  74. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/agents/orchestration/report.py +0 -0
  75. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/__init__.py +0 -0
  76. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/contract.py +0 -0
  77. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/core_insights.py +0 -0
  78. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/dataset_fetcher.py +0 -0
  79. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/football_data.py +0 -0
  80. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/fpl.py +0 -0
  81. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/fpl_draft.py +0 -0
  82. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/historical.py +0 -0
  83. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/historical_types.py +0 -0
  84. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/__init__.py +0 -0
  85. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/_http.py +0 -0
  86. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/_models.py +0 -0
  87. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/anthropic.py +0 -0
  88. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/openai_compat.py +0 -0
  89. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/providers/perplexity.py +0 -0
  90. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/understat.py +0 -0
  91. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/api/vaastav.py +0 -0
  92. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/__init__.py +0 -0
  93. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_banner.py +0 -0
  94. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_context.py +0 -0
  95. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_fines.py +0 -0
  96. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_fines_config.py +0 -0
  97. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_json.py +0 -0
  98. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_data.py +0 -0
  99. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_history.py +0 -0
  100. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_types.py +0 -0
  101. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_plan_grid.py +0 -0
  102. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_analysis.py +0 -0
  103. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/_review_summarisation.py +0 -0
  104. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/allocate.py +0 -0
  105. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/captain.py +0 -0
  106. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/chips.py +0 -0
  107. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/credentials.py +0 -0
  108. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/differentials.py +0 -0
  109. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/doctor.py +0 -0
  110. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/doctor_providers.py +0 -0
  111. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/fdr.py +0 -0
  112. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/fixtures.py +0 -0
  113. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/history.py +0 -0
  114. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/init.py +0 -0
  115. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/intel.py +0 -0
  116. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/league_fines.py +0 -0
  117. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/league_recap.py +0 -0
  118. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/player.py +0 -0
  119. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/preview.py +0 -0
  120. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/price_changes.py +0 -0
  121. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/price_history.py +0 -0
  122. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/ratings.py +0 -0
  123. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/returnees.py +0 -0
  124. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/review.py +0 -0
  125. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/sell_prices.py +0 -0
  126. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/squad.py +0 -0
  127. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/stats.py +0 -0
  128. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/status.py +0 -0
  129. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/targets.py +0 -0
  130. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/transfer_eval.py +0 -0
  131. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/waivers.py +0 -0
  132. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/cli/xg.py +0 -0
  133. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/defaults.yaml +0 -0
  134. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/fixture_predictions.yaml +0 -0
  135. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
  136. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/config/team_managers.yaml +0 -0
  137. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/constants.py +0 -0
  138. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/__init__.py +0 -0
  139. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/chip_plan.py +0 -0
  140. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/fixture.py +0 -0
  141. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/league_history.py +0 -0
  142. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/player.py +0 -0
  143. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/team.py +0 -0
  144. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/models/types.py +0 -0
  145. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/parsers/__init__.py +0 -0
  146. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/parsers/recommendations.py +0 -0
  147. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/paths.py +0 -0
  148. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/__init__.py +0 -0
  149. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/league_recap.py +0 -0
  150. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/returnees.py +0 -0
  151. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/review.py +0 -0
  152. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/prompts/scout.py +0 -0
  153. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/scraper/__init__.py +0 -0
  154. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/scraper/fpl_prices.py +0 -0
  155. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/season.py +0 -0
  156. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/__init__.py +0 -0
  157. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/fixture_predictions.py +0 -0
  158. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history.py +0 -0
  159. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history_counters.py +0 -0
  160. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history_fines.py +0 -0
  161. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/league_history_notes.py +0 -0
  162. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/matchup.py +0 -0
  163. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/player_clubs.py +0 -0
  164. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/player_prior.py +0 -0
  165. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/returnee_radar.py +0 -0
  166. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/__init__.py +0 -0
  167. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/constants.py +0 -0
  168. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/data_prep.py +0 -0
  169. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/display.py +0 -0
  170. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/evaluation.py +0 -0
  171. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/ownership.py +0 -0
  172. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/shrinkage.py +0 -0
  173. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/signals.py +0 -0
  174. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/single_gw.py +0 -0
  175. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/scoring/value_quality.py +0 -0
  176. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/season_previews.py +0 -0
  177. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/squad_allocator.py +0 -0
  178. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/team_form.py +0 -0
  179. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/team_ratings.py +0 -0
  180. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/services/team_ratings_prior.py +0 -0
  181. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
  182. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/templates/gw_preview.md.j2 +0 -0
  183. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/templates/gw_review.md.j2 +0 -0
  184. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/__init__.py +0 -0
  185. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/files.py +0 -0
  186. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/gameweek.py +0 -0
  187. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/markdown.py +0 -0
  188. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/teams.py +0 -0
  189. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/text.py +0 -0
  190. {fplkit-2.7.3 → fplkit-2.7.4}/fpl_cli/utils/time.py +0 -0
  191. {fplkit-2.7.3 → fplkit-2.7.4}/pyproject.toml +0 -0
  192. {fplkit-2.7.3 → fplkit-2.7.4}/pyrightconfig.json +0 -0
  193. {fplkit-2.7.3 → fplkit-2.7.4}/scripts/backtest_early_season_prior_blend.py +0 -0
  194. {fplkit-2.7.3 → fplkit-2.7.4}/scripts/calibrate_promoted_prior.py +0 -0
  195. {fplkit-2.7.3 → fplkit-2.7.4}/scripts/calibrate_quality_ceilings.py +0 -0
  196. {fplkit-2.7.3 → fplkit-2.7.4}/tests/__init__.py +0 -0
  197. {fplkit-2.7.3 → fplkit-2.7.4}/tests/conftest.py +0 -0
  198. {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/classic_squad_fixture.md +0 -0
  199. {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/recommendations_fixture.md +0 -0
  200. {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
  201. {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
  202. {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
  203. {fplkit-2.7.3 → fplkit-2.7.4}/tests/fixtures/waiver_ranked_golden.json +0 -0
  204. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_action.py +0 -0
  205. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_analysis.py +0 -0
  206. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_analysis_squad.py +0 -0
  207. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_base.py +0 -0
  208. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_data.py +0 -0
  209. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_data_price.py +0 -0
  210. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_agents_orchestration.py +0 -0
  211. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_allocate.py +0 -0
  212. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_client.py +0 -0
  213. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_core_insights.py +0 -0
  214. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_dataset_fetcher.py +0 -0
  215. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_draft.py +0 -0
  216. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_football_data.py +0 -0
  217. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_historical.py +0 -0
  218. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_understat.py +0 -0
  219. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_api_vaastav.py +0 -0
  220. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_bench_order_script.py +0 -0
  221. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_calibrate_quality_ceilings.py +0 -0
  222. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_classic_squad_extraction.py +0 -0
  223. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_allocate.py +0 -0
  224. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_banner.py +0 -0
  225. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_captain.py +0 -0
  226. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_chips.py +0 -0
  227. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_context_settings.py +0 -0
  228. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_credentials.py +0 -0
  229. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_custom_analysis.py +0 -0
  230. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_differentials.py +0 -0
  231. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_doctor.py +0 -0
  232. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_doctor_providers.py +0 -0
  233. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_failure_parity.py +0 -0
  234. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_failure_streams.py +0 -0
  235. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fdr.py +0 -0
  236. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fdr_blanks.py +0 -0
  237. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fixtures.py +0 -0
  238. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_format.py +0 -0
  239. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_format_suppression.py +0 -0
  240. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_fpl_block.py +0 -0
  241. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_history.py +0 -0
  242. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_init.py +0 -0
  243. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_intel.py +0 -0
  244. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_json.py +0 -0
  245. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_json_contract.py +0 -0
  246. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_league_fines.py +0 -0
  247. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_league_recap.py +0 -0
  248. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_plan_grid.py +0 -0
  249. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_player.py +0 -0
  250. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_preview.py +0 -0
  251. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_price_changes.py +0 -0
  252. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_price_history.py +0 -0
  253. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_returnees.py +0 -0
  254. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_sell_prices.py +0 -0
  255. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_settings_warning.py +0 -0
  256. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_squad.py +0 -0
  257. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_stats.py +0 -0
  258. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_status.py +0 -0
  259. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_targets.py +0 -0
  260. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_transfer_eval.py +0 -0
  261. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_waivers.py +0 -0
  262. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_warnings_stderr.py +0 -0
  263. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_cli_xg.py +0 -0
  264. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_common.py +0 -0
  265. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_common_scoring.py +0 -0
  266. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_data_prep_harmonisation.py +0 -0
  267. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_dependencies.py +0 -0
  268. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_doc_consistency.py +0 -0
  269. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fines.py +0 -0
  270. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fines_config.py +0 -0
  271. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fixture_predictions.py +0 -0
  272. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_formation_guidance_sync.py +0 -0
  273. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_fpl_prices.py +0 -0
  274. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_gw_prep_script_bootstrap.py +0 -0
  275. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_init.py +0 -0
  276. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_init_fines.py +0 -0
  277. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_counters.py +0 -0
  278. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_fines.py +0 -0
  279. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_notes.py +0 -0
  280. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_league_history_store.py +0 -0
  281. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_matchup_service.py +0 -0
  282. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_models.py +0 -0
  283. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_normalise_entities.py +0 -0
  284. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_output_style_sync.py +0 -0
  285. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_paths.py +0 -0
  286. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_player_clubs.py +0 -0
  287. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_player_prior.py +0 -0
  288. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_player_resolution.py +0 -0
  289. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_preseason.py +0 -0
  290. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_prompt_tables.py +0 -0
  291. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_providers.py +0 -0
  292. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_recommendations_parser.py +0 -0
  293. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_report_agent.py +0 -0
  294. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_report_paths.py +0 -0
  295. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_returnee_radar.py +0 -0
  296. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_review.py +0 -0
  297. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_rolling_pts_per_m.py +0 -0
  298. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_scoring.py +0 -0
  299. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_scraper.py +0 -0
  300. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_season.py +0 -0
  301. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_season_awareness.py +0 -0
  302. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_season_previews.py +0 -0
  303. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_sell_prices.py +0 -0
  304. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_skill_fdr_quality_signals.py +0 -0
  305. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_skill_stats_thresholds.py +0 -0
  306. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_squad_allocator.py +0 -0
  307. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_starting_xi_agent.py +0 -0
  308. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_starting_xi_script.py +0 -0
  309. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_stats.py +0 -0
  310. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_team_ratings.py +0 -0
  311. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_team_ratings_prior.py +0 -0
  312. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_transfer_eval_agent.py +0 -0
  313. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_transfer_eval_script.py +0 -0
  314. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_gameweek.py +0 -0
  315. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_markdown.py +0 -0
  316. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_text.py +0 -0
  317. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_utils_time.py +0 -0
  318. {fplkit-2.7.3 → fplkit-2.7.4}/tests/test_validate_draft_waivers.py +0 -0
@@ -39,7 +39,7 @@ with `2>/dev/null` without losing the table, whichever command it runs.
39
39
  | `player` | Look up a player's stats, xG, ownership and fixture run. Before GW10 `quality_score` blends last season's pedigree in and JSON `metadata.warnings` says so (`early_season_prior_informed`, or `early_season_small_sample` when history could not be loaded), same as `fpl stats --value`; `understat_team_unmatched` joins it for any club Understat carried no players for, whose whole squad loses xG enrichment. JSON includes `ep_next` (predicted pts next GW) and `ep_this` (current GW), emitting `null` when FPL has no projection; panel shows `xPts` (ep_next) when available, omits the segment when FPL has no projection. `--detail` (`-d`) shows GW-by-GW performance and, for FWD/MID with sufficient history, xGI sustainability (per-match GI-xGI divergence -> form modifier). With `custom_analysis`: JSON adds `info.adjusted_npxg_per_90` (fixture-adjusted) and `info.raw_npxg_per_90` (Understat season avg); panel shows `adj. npxG/90: X.XXX (raw: Y.YYY)` | Yes | General | No | direct-api |
40
40
  | `stats` | List players with filtering and sorting. `--value` adds quality/value per £m columns; `--window N` sets rolling lookback (3-10) for `rolling_pts_per_m`. Before GW10, `--value` quality scores blend last season's pts/90 pedigree with this season's observation and the command says so (stderr notice quoting the observation's weight band; JSON `metadata.warnings` code `early_season_prior_informed`); when last season's history cannot be loaded they are pure observation and the notice says that instead (`early_season_small_sample`, before GW6). `understat_team_unmatched` is raised once per club Understat carried no players for, whose whole squad scores without xG. GK ceilings scale with the calendar-possible sample, reaching full scale at GW6. Sortable by `ep_next`/`ep_this` (FPL predicted points; `ep_this` is only meaningful before the gameweek's first kickoff — FPL rolls it forward to `ep_next` as each player's match finishes); both in JSON output (emit `null` when FPL has no projection; table renders `—`). Players with no projection sort to the bottom in either direction | Yes | General | No | direct-api |
41
41
  | `history` | Show historical player performance across seasons. A provider that refuses exits 1 in both output formats, carrying the upstream's own reason: the error envelope used to say only `Failed to fetch historical data` while the cause went to stderr, and table mode printed the cause to stdout and exited 0 | Yes | General | No | direct-api |
42
- | `league` | Show live league standings for Classic and Draft leagues. Classic positions are competition-ranked from cumulative totals — managers level on points share a place and the next distinct total skips the places they consumed — matching `fpl review` and the league history ledger rather than the standings API's sequential rank, which splits such a tie on fewest transfers season-to-date, a figure no column shows. Draft keeps the league's own rank, which already applies the head-to-head points-for tie-break | No | General | No | direct-api |
42
+ | `league` | Show live league standings for Classic and Draft leagues. Classic positions are competition-ranked from cumulative totals — managers level on points share a place and the next distinct total skips the places they consumed — matching `fpl review` and the league history ledger rather than the standings API's sequential rank, which splits such a tie on fewest transfers season-to-date, a figure no column shows. Draft keeps the league's own rank, which already applies the head-to-head points-for tie-break. The Best/Worst GW Performers lists (top three; bottom five classic, bottom three draft) are the ones `fpl review` prints: managers level on the gameweek share a place (`3=`) and a tie at the cut keeps every manager in it, a hit reads `29 net pts (33 gross, -4 hit)`, and a classic user above the bottom five gets a `Your GW rank: R of N` line beside the list | No | General | No | direct-api |
43
43
  | `fdr` | Analyse fixture difficulty - easy runs, blanks, doubles. Three payloads, not one: the default call runs the agent and returns the full analysis plus `data.ratings_warning` (the team-ratings quality verdict, and with `--my-squad` also `data.predictions_stale` / `data.prediction_warnings` / `data.squad_exposure`); with custom analysis off it returns raw 1-5 FPL API difficulty (`data.easy_fixture_runs` only, `metadata.custom_analysis: false`, no ratings and so no warning); `--blanks` bypasses the agent for a schedule-only payload carrying neither the FDR analysis nor `ratings_warning`. `--blanks` mixes confirmed fixtures with predictions; when those predictions are stale, table mode warns on stderr and JSON adds a `fixture_predictions_stale` entry to `metadata.warnings` | Yes | General | No | via-agent |
44
44
  | `xg` | Analyse underlying stats: xG, xA, overperformers. The minutes floor and the window scale to the gameweeks played (`min(60 x window, 45 x gameweeks played)`, so 90 minutes over 2 GWs rather than an unreachable 360 over 6); the configured bar binds from GW9, or GW11 for `--all`. JSON `metadata` carries `window_label`, `gameweeks_played` and `min_minutes`, and `metadata.warnings` an `early_season_minutes_floor` notice while the bar is scaled, plus `understat_team_unmatched` for any club Understat carried no players for. When nothing qualifies, `data.empty_reason` says whether the floor (`below_minutes_floor`) or the data (`no_minutes_played`) caused it | Yes | General | No | via-agent |
45
45
  | `price-changes` | Show price changes and transfer activity | No | General | No | via-agent |
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
+ ## [2.7.3] - 2026-10-09
5
+
6
+ ### Bug Fixes
7
+
8
+ - default synthesis model moves to claude-sonnet-5-5 (#362)
9
+ - stop streak notes calling a fully recorded gameweek "not recorded" (#363)
10
+ - show correct ranks and scores in the gameweek review report (#365)
11
+ - review no longer drops the transfers summary on a net-zero total (#366)
12
+ - draft review no longer misreports rendering errors as a failed fetch (#369)
13
+ - stop gw-prep, squad-builder and FPL Mate ruling out a legal 5-2-3 (#370)
14
+ - flag a recap editorial that misnames who lost a contested waiver (#368)
15
+ - draft review no longer crashes on bracketed player or manager names (#373)
16
+ - classic review no longer crashes on bracketed names (#378)
17
+ - flag recap editorials that misplace a transfer net or invent a tie (#374)
18
+
4
19
  ## [2.7.2] - 2026-09-16
5
20
 
6
21
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fplkit
3
- Version: 2.7.3
3
+ Version: 2.7.4
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
@@ -597,7 +597,7 @@ fpl_cli/
597
597
  │ ├── review.py # Review research + synthesis prompts. Squad and transfer lines in the synthesis data block carry each player's full club name, and the hard constraints bind club affiliation and blanket scored/blanked claims to that supplied data — an LLM's own club knowledge goes stale every transfer window. The synthesis prompt also carries a `<next_gameweek>` block — every club's next fixture and each squad player's positional FDR, built by `_review_next_gameweek` / `_format_next_gameweek` — and the "Next Week" instruction switches on whether it is there: grounded advice when it is, fixture-independent observations pointing at `fpl gw-prep` when it is not, because a results-only review was issuing confident fixture-dependent calls with no fixture data behind them (#191), with `check_next_week_grounding` checking the answer back against that block -- a cited FDR figure the block never printed, or any fixture claim on a run that had no block -- so the grounding claim is not left resting on prompt prose alone, the way the research half's rules are backed by `validate_research_teams`, which folds both the supplied name and the model's cell through `normalise_name` before the allowlist search so a reformat is not read as an invention, and leaves a `[table trimmed: N unlisted row(s) removed]` line under a table it did shorten — the strip notice otherwise reaching stderr and `--debug` only, both gone by the time the markdown is read (#343). Also the synthesis half's post-generation guard (`check_synthesis_completeness`, `required_synthesis_sections`), which reads the required `## ` headings back off the prompt that was actually sent rather than restating them as a constant. The research half's counts are grounded the same way: `fixture_aggregates()` computes the fixture, goal, clean-sheet and goalless-draw totals once, `FixtureAggregates.summary_line()` pins them at the top of the GW Results block as the narrative's only permitted source, and `validate_research_counts` checks the narrative back against the same object — a count the prompt pins cannot drift from the count the check enforces. It rewrites a contradicting figure in place rather than scrubbing the sentence, and corrects one only when neither its sentence nor the one before it names anyone the count could belong to — a club, a nickname, a player, a pronoun standing in for one — because a round-wide total is exactly the sentence that names nobody. The guard errs towards leaving prose alone: a missed correction ships the model's own words, while a wrong one fabricates a claim in prose that was true. That blind spot is why the check was needed at all — the narrative that reported six clean sheets as four (#324) named no player, so all three name-based validators passed it
598
598
  │ ├── returnees.py # Return-intel search prompt for `fpl returnees --enrich` (one player per query, so a citation list belongs to a single player)
599
599
  │ ├── league_recap.py # League recap synthesis prompts, including the anchored League History section, its never-infer-history rule (emitted even when the pack is empty), and the season-phase framing instruction. The GW Standings section leads with an explicit "Previous gameweek's leader" fact (issue #189) so a "topped the table"/"fell from the top" claim is checkable against a stated name instead of inferred from the Prev column or the size of a fall, and the rules forbid merging two managers' separate League History entries into a shared record or "club". `collect_player_clubs()` regroups the club each squad/transfer row already resolved off its `team_id` by the name the prose uses, feeding both a `## Player Clubs` roster and the club printed inline on each captain group, so club claims bind to supplied data instead of recall; a name the data gives two clubs for is dropped rather than guessed at, since the prose can't tell the two players apart. Transfers get the same full-roster treatment as captains and chips (issue #71): `format_recap_transfers_context()` lists every manager who transferred with each move, the hit and the post-hit net, names who made none, and keeps a manager whose moves the collector could not capture among the movers with the moves marked unknown, so the rule pins transfer narration to that section rather than to the two award winners — the only transfer data the model used to see, which it read as the whole story. `format_recap_waivers_context()` is the draft half (issue #301): every waiver and free-agent move as it was made, never chain-contracted (that is the awards' display compression, not a record of activity), each tagged by kind through the shared `draft_transaction_kind_label()` / `draft_transaction_kind_counts()` in `_league_recap_types.py` so the roster and the waiver awards never describe one move in two vocabularies; the rule names that section as draft's source of truth and only falls back to "say nothing about moves" when neither roster is present. It states three groups rather than two (issue #329): movers, the managers who claimed a player and lost him to a rival, and the managers who did neither. A waiver is a competition, so an outbid manager ends the gameweek with no accepted move and used to be indistinguishable from one who submitted nothing -- the editorial read that absence as inactivity and assigned a motive for it, so the rule now forbids calling anyone in the outbid group idle. The section closes with the gameweek's contested players (issue #330) — one sentence per player more than one manager claimed, from `contested_draft_claims()` / `format_contested_claim()` in `_league_recap_types.py`, the same sentence the Most Contested award prints for a pile-up of `MOST_CONTESTED_MIN_CLAIMANTS` or more that a recapped manager won (a race nobody can be seen to have won is context, never the award), with a rule pinning who-wanted-whom to those lines and limiting what may be said of anyone's waiver order to the winner having stood higher in it. `format_recap_prior_seasons_context()` (issue #131) is the opener's one source of history: each classic manager's FPL-wide seasons before this one, folded once by `summarise_prior_seasons()` (`_league_recap_types.py`) so the saved report and the prompt read the same lines, with an explicit count, the no-record and unfetched managers named rather than omitted, and a rule pinning tenure to FPL-wide phrasing — entry and league IDs are reissued each season, so "Nth season in this league" is unknowable and the section says so. The report's title is not the model's (issue #349): `recap_title()` (`_league_recap_types.py`) is the one H1, read by the template that renders it and the prompt that names it as already written; the prompt asks for a `## ` headline beneath it and no title, and `normalise_recap_editorial()` holds the reply to that shape in one fence-aware pass over every heading — one that only restates the title is dropped wherever it sits, the opening one is kept at H2 with the title's parts taken off (emphasis unwrapped through `unwrap_emphasis()` in `utils/markdown.py`, balanced pairs only, so what is printed again stays well-formed), a later `# ` is demoted, and a title-shaped part is removed only behind a colon, pipe or spaced dash so a hook's own words are never edited — since an instruction is not a contract and the model had mirrored, restyled, renamed and skipped the heading in turn. For the same reason the finished editorial is checked against its own data afterwards, by `league_recap_checks.py` beside this module; what the checks hold "joint" to is stated here, in `_standings_tie_lines()`: the week's highest and lowest scores, every gameweek score and Pos position more than one manager holds, any shared Prev position, and any season totals the league's tie-break placed apart (a head-to-head draft table ranks managers level on league points by points scored)
600
- │ └── league_recap_checks.py # Post-generation checks on the league-recap editorial, kept out of the prompt module so it stays about prompts. `EDITORIAL_CHECKS` is the one registry -- each check's `RECAP_WARNING_*` code, the lead of its JSON warning (`editorial_warning_message()`, one sentence per claim), its retry instruction and the checker -- so a new check is one record. `check_contested_attributions()` (issue #357) reads who a sentence says won or was beaten to a contested player; `check_net_attributions()` (issue #359) reads a "net" figure said of one named move and flags it when it is another figure on that manager's roster line, attaching it to whichever of the move and its manager is named nearer before it; `check_tie_claims()` (issue #359) reads a tie word fused to a rank ("joint-lowest", "tied for last", "joint-seventh") against the GW Pts column, or the table's Pos, Prev and Total columns. Each reads only the clauses it can pin down and lets the rest pass, so a problem is a sentence the data contradicts and a false alarm (which costs a retry and a warning above a correct editorial) is the failure it is built against. `check_recap_editorial()` runs them all; the command regenerates once with `get_recap_editorial_retry_prompt()` (the wrong claims plus an instruction per kind that failed), saves a still-failing editorial as written with one JSON warning per code and the claims in a callout above it, and ships the editorial unchecked if a check itself raises
600
+ │ └── league_recap_checks.py # Post-generation checks on the league-recap editorial, kept out of the prompt module so it stays about prompts. `EDITORIAL_CHECKS` is the one registry -- each check's `RECAP_WARNING_*` code, the lead of its JSON warning (`editorial_warning_message()`, one sentence per claim), its retry instruction and the checker -- so a new check is one record. `check_contested_attributions()` (issue #357) reads who a sentence says won or was beaten to a contested player; `check_net_attributions()` (issue #359) reads a "net" figure said of one named move and flags it when it is another figure on that manager's roster line, attaching it to whichever of the move and its manager is named nearer before it; `check_tie_claims()` (issue #359) reads a tie word fused to a rank ("joint-lowest", "tied for last", "joint-seventh") against the GW Pts column, or the table's Pos, Prev and Total columns. All three read names the same way: `check_recap_editorial()` builds one `RecapNames` per editorial -- a longest-first alternation over every name the data carries (every squad, captain, move and lost claim's players, the managers and their unique short forms) -- and each check takes it as a required argument and keeps only the matches it is after, so a manager's surname is never read inside a hyphenated player ("White" in "Gibbs-White") nor a player inside a manager's full name (issue #379). Each reads only the clauses it can pin down and lets the rest pass, so a problem is a sentence the data contradicts and a false alarm (which costs a retry and a warning above a correct editorial) is the failure it is built against. `check_recap_editorial()` runs them all; the command regenerates once with `get_recap_editorial_retry_prompt()` (the wrong claims plus an instruction per kind that failed), saves a still-failing editorial as written with one JSON warning per code and the claims in a callout above it, and ships the editorial unchecked if a check itself raises
601
601
  ├── parsers/
602
602
  │ └── recommendations.py # Parse gw{N}-recommendations.md into structured decisions
603
603
  ├── scraper/
@@ -760,6 +760,15 @@ A waiver claim a rival won is reported under **Claims Lost** rather than discard
760
760
 
761
761
  **LLM summary** (`--summarise`): Community narrative via research provider, personal analysis via synthesis provider. The summary is an add-on: the review itself needs no key, so a role whose provider has no usable key is skipped with the reason on stderr (`Community narrative skipped: ...` / `Personal analysis skipped: ...`) and the review still prints, still saves its report and still exits **0**. The two roles resolve independently, so one key buys the half it belongs to rather than nothing. A saved report generated from such a run names each skipped half and why in a warning callout above the summary, so a section absent for want of a key never reads as one deliberately left out.
762
762
 
763
+ **Trimmed tables:** the community narrative's Standout Performers and Disappointments tables are
764
+ checked against the player lists the research prompt was given. A row naming a player who is not
765
+ on those lists is removed, and the table then carries `[table trimmed: N unlisted row(s)
766
+ removed]` beneath it, counted per table. "Unlisted" means not on the supplied lists, and the
767
+ guard removed the rows, not the model, so a shortened table is not the model's own choice.
768
+ Style differences (spacing, apostrophes, accents, case) are never grounds for removal. Under
769
+ `--debug`, each removed row is named in `data/debug/research_corrections.txt` as
770
+ `<player>: stripped (not in provided list)`.
771
+
763
772
  **Next Week:** the personal analysis is given next gameweek's fixtures — every club's opponent
764
773
  and venue, and each of your own players' FDR for their own position — so the section's start,
765
774
  bench and transfer calls are grounded in what the squad actually plays rather than extrapolated
@@ -826,6 +835,13 @@ a season with nothing finished yet — is refused with the reason on **stderr**
826
835
  with the reason on stdout, as did `league-recap`, which shares the resolver. A missing provider
827
836
  key was refused the same way until it stopped being a refusal at all — see the LLM summary above.
828
837
 
838
+ **Notices** — the line that says an entry id is not configured or not found in the draft league,
839
+ the pair that says why a past gameweek has no league table, the note that a new mini-league's
840
+ standings are not published yet, and the `Fetching your team data...` progress line — go to
841
+ **stderr**, for classic and draft alike. A section heading stays on stdout only with the content it
842
+ introduces: for a past gameweek, or a mini-league whose table is not published yet, there is no
843
+ League section on stdout at all and stderr says why.
844
+
829
845
  ### League Recap
830
846
 
831
847
  Entertainment-first post-gameweek report for the whole league.
@@ -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.7.3'
22
- __version_tuple__ = version_tuple = (2, 7, 3)
21
+ __version__ = version = '2.7.4'
22
+ __version_tuple__ = version_tuple = (2, 7, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -6,9 +6,12 @@ import asyncio
6
6
  import logging
7
7
  from collections import Counter
8
8
  from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
9
10
  from typing import TYPE_CHECKING, Any
10
11
 
11
- from fpl_cli.cli._context import fpl_config
12
+ from rich.markup import escape as rich_escape
13
+
14
+ from fpl_cli.cli._context import console, error_console, fpl_config
12
15
 
13
16
  if TYPE_CHECKING:
14
17
  from fpl_cli.api.fpl import FPLClient
@@ -121,6 +124,157 @@ def your_gw_rank_line(gw_rank: str | None, field_size: int | None, row: Mapping[
121
124
  return f"Your GW rank: {gw_rank or '?'}{field} - {performer_score(row)}"
122
125
 
123
126
 
127
+ def print_historical_league_notice(gw: int) -> None:
128
+ """Say why a past gameweek's review carries no league table (#377, #382).
129
+
130
+ The classic and draft blocks print the same two lines under their own
131
+ `## League` heading. They are commentary on the review, not part of it, so
132
+ they go to stderr and the heading stays on stdout with the content it
133
+ introduces.
134
+ """
135
+ error_console.print(f"[dim]League standings not shown for historical GW{gw} review[/dim]")
136
+ error_console.print("[dim]Use 'fpl league' for current standings[/dim]")
137
+
138
+
139
+ def _performer_row(e: Mapping[str, Any]) -> dict[str, Any]:
140
+ """A classic standings entry as a Best/Worst GW Performers row.
141
+
142
+ Takes an entry from `_fetch_standings_with_costs` after `_assign_tie_ranks`.
143
+ `points` is the score the row was ranked on (net when the league plays
144
+ net), which is what `performer_score` and the last-place fine read.
145
+ """
146
+ return {
147
+ "name": e["name"],
148
+ "points": e["net_points"],
149
+ "gross_points": e["gross_points"],
150
+ "transfer_cost": e["transfer_cost"],
151
+ "rank_str": e["rank_str"],
152
+ "is_user": e.get("is_user", False),
153
+ }
154
+
155
+
156
+ def _draft_performer_row(e: Mapping[str, Any], user_entry_id: int | None) -> dict[str, Any]:
157
+ """A draft standings entry as a Best/Worst GW Performers row.
158
+
159
+ Draft has no hits, so `points` is the gameweek score as it stands and
160
+ `performer_score` states it without a gross or a hit beside it.
161
+ """
162
+ return {
163
+ "name": e["manager_name"],
164
+ "points": e["event_total"],
165
+ "rank_str": e["rank_str"],
166
+ # An unset draft_entry_id must not match an entry the league details
167
+ # left without an entry id, which would print a rival as "You".
168
+ "is_user": user_entry_id is not None and e["entry_id"] == user_entry_id,
169
+ }
170
+
171
+
172
+ def _print_performer(perf: Mapping[str, Any]) -> None:
173
+ """Print one Best/Worst GW Performers row to the terminal.
174
+
175
+ The one renderer `fpl review` and `fpl league` share, for both formats:
176
+ `fpl league` kept its own copy, which numbered a tie as a placing and
177
+ worded a hit its own way (#381).
178
+ """
179
+ name = "[bold cyan]You[/bold cyan]" if perf["is_user"] else rich_escape(perf["name"])
180
+ console.print(f" {perf['rank_str']}. {name} - {performer_score(perf)}")
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class GwPerformers:
185
+ """A league's Best/Worst GW Performers, ranked for every command that shows them.
186
+
187
+ `best` and `worst` are performer rows. `user_gw_rank` is the user's
188
+ place on the gameweek ("3="), None when they are not in the standings
189
+ handed over. `user_context_row` is the user's own row when they sit
190
+ above the worst list: it is shown beside the list as a "Your GW rank"
191
+ line, never as a numbered row in it that reads as a bottom placing
192
+ (#360). `field_size` is None when the standings were one page of a
193
+ larger league.
194
+ """
195
+
196
+ best: list[dict[str, Any]]
197
+ worst: list[dict[str, Any]]
198
+ user_gw_rank: str | None
199
+ user_context_row: dict[str, Any] | None
200
+ field_size: int | None
201
+
202
+
203
+ def _gw_performers(
204
+ entries: list[dict[str, Any]],
205
+ score_key: str,
206
+ to_row: Callable[[Mapping[str, Any]], dict[str, Any]],
207
+ *,
208
+ worst_n: int,
209
+ complete: bool,
210
+ with_context_row: bool,
211
+ ) -> GwPerformers:
212
+ """Rank *entries* on *score_key* into the top three and the bottom *worst_n*.
213
+
214
+ Each list runs past its cut to keep a tie whole, numbered as a shared
215
+ place. Both passes rank the same dicts in place, so each pass's rows
216
+ are built before the next one re-ranks them.
217
+ """
218
+ desc = sorted(entries, key=lambda e: e[score_key], reverse=True)
219
+ _assign_tie_ranks(desc, score_key)
220
+ best = [to_row(e) for e in _slice_with_ties(desc, 3)]
221
+ user_row = next((row for row in map(to_row, desc) if row["is_user"]), None)
222
+
223
+ asc = sorted(entries, key=lambda e: e[score_key])
224
+ _assign_tie_ranks(asc, score_key)
225
+ worst = [to_row(e) for e in _slice_with_ties(asc, worst_n)]
226
+
227
+ user_in_worst = any(row["is_user"] for row in worst)
228
+ return GwPerformers(
229
+ best=best,
230
+ worst=worst,
231
+ user_gw_rank=user_row["rank_str"] if user_row else None,
232
+ user_context_row=user_row if with_context_row and not user_in_worst else None,
233
+ field_size=len(entries) if complete else None,
234
+ )
235
+
236
+
237
+ def classic_gw_performers(standings_with_costs: list[dict[str, Any]], *, complete: bool) -> GwPerformers:
238
+ """Classic Best/Worst GW Performers: top three and bottom five plus ties, on net points.
239
+
240
+ Takes `_fetch_standings_with_costs` output. `complete` is False when the
241
+ standings were one 50-entry page of a larger league: the GW ranks then
242
+ cover only that page, so no field size is named that would read as the
243
+ whole league. `fpl review` and `fpl league` both rank through here: they
244
+ each ran this sequence themselves and drifted apart on it (#381).
245
+ """
246
+ return _gw_performers(
247
+ standings_with_costs, "net_points", _performer_row,
248
+ worst_n=5, complete=complete, with_context_row=True,
249
+ )
250
+
251
+
252
+ def draft_gw_performers(standings_with_names: list[dict[str, Any]], user_entry_id: int | None) -> GwPerformers:
253
+ """Draft Best/Worst GW Performers: top and bottom three plus ties, on the GW score.
254
+
255
+ Takes standings rows carrying `manager_name`, `entry_id` and
256
+ `event_total`. Draft review has never printed a "Your GW rank" line, so
257
+ neither does this, and `user_context_row` is always None.
258
+ """
259
+ return _gw_performers(
260
+ standings_with_names, "event_total", lambda e: _draft_performer_row(e, user_entry_id),
261
+ worst_n=3, complete=True, with_context_row=False,
262
+ )
263
+
264
+
265
+ def _print_gw_performers(performers: GwPerformers, *, header_suffix: str = "") -> None:
266
+ """Print both performer lists, and the user's GW rank line when they sit above the worst one."""
267
+ console.print(f"\n[bold]### Best GW Performers{header_suffix}[/bold]")
268
+ for perf in performers.best:
269
+ _print_performer(perf)
270
+ console.print(f"\n[bold]### Worst GW Performers{header_suffix}[/bold]")
271
+ for perf in performers.worst:
272
+ _print_performer(perf)
273
+ if performers.user_context_row:
274
+ line = your_gw_rank_line(performers.user_gw_rank, performers.field_size, performers.user_context_row)
275
+ console.print(f" {line}")
276
+
277
+
124
278
  def _gw_position_with_half(position: int | str, total: int) -> str:
125
279
  """Return position string annotated with pre-computed half classification.
126
280
 
@@ -12,17 +12,16 @@ from rich.table import Table
12
12
 
13
13
  from fpl_cli.cli._context import console, error_console
14
14
  from fpl_cli.cli._helpers import (
15
- _assign_tie_ranks,
16
15
  _center_window_with_ties,
17
16
  _fetch_standings_with_costs,
18
17
  _format_pts_display,
19
18
  _format_review_player,
20
19
  _live_player_stats,
21
20
  _net_transfer_ids,
22
- _slice_with_ties,
23
- performer_score,
21
+ _print_gw_performers,
22
+ classic_gw_performers,
23
+ print_historical_league_notice,
24
24
  signed_net_markup,
25
- your_gw_rank_line,
26
25
  )
27
26
  from fpl_cli.cli._league_recap_data import derive_point_in_time_positions
28
27
  from fpl_cli.services.fixture_predictions import is_blank_gameweek, is_double_gameweek
@@ -74,7 +73,7 @@ async def _review_classic_team(
74
73
 
75
74
  if entry_id:
76
75
  try:
77
- console.print("[dim]Fetching your team data...[/dim]")
76
+ error_console.print("[dim]Fetching your team data...[/dim]")
78
77
  picks_response = await client.get_manager_picks(entry_id, gw)
79
78
 
80
79
  entry_history = picks_response.get("entry_history", {})
@@ -262,7 +261,7 @@ async def _review_classic_team(
262
261
  elif entry_id:
263
262
  error_console.print("[yellow]Could not fetch your team data[/yellow]")
264
263
  else:
265
- console.print("[dim]Set classic_entry_id in config/settings.yaml to see your squad[/dim]")
264
+ error_console.print("[dim]Set classic_entry_id in config/settings.yaml to see your squad[/dim]")
266
265
 
267
266
  return {
268
267
  "my_entry_summary": my_entry_summary,
@@ -391,27 +390,6 @@ async def _review_classic_transfers(
391
390
  return classic_transfers_data
392
391
 
393
392
 
394
- def _performer_row(e: dict[str, Any]) -> dict[str, Any]:
395
- """A standings entry as a Best/Worst GW Performers row.
396
-
397
- `points` is the score the row was ranked on (net when the league plays
398
- net), which is what `performer_score` and the last-place fine read.
399
- """
400
- return {
401
- "name": e["name"],
402
- "points": e["net_points"],
403
- "gross_points": e["gross_points"],
404
- "transfer_cost": e["transfer_cost"],
405
- "rank_str": e["rank_str"],
406
- "is_user": e.get("is_user", False),
407
- }
408
-
409
-
410
- def _print_performer(perf: dict[str, Any]) -> None:
411
- name = "[bold cyan]You[/bold cyan]" if perf["is_user"] else rich_escape(perf["name"])
412
- console.print(f" {perf['rank_str']}. {name} - {performer_score(perf)}")
413
-
414
-
415
393
  async def _review_classic_league(
416
394
  client: FPLClient,
417
395
  classic_league_id: int | None,
@@ -444,12 +422,12 @@ async def _review_classic_league(
444
422
  league_name = standings_data.get("league", {}).get("name", "Classic League")
445
423
  standings = standings_data.get("standings", {}).get("results", [])
446
424
 
447
- # Check if we're reviewing a historical GW (league data would be stale)
425
+ # Check if we're reviewing a historical GW (league data would be stale).
426
+ # The heading is printed only on the path that has a table for it to
427
+ # introduce: the two below say why there is none, on stderr.
448
428
  is_historical_review = api_current_gw_id is not None and gw != api_current_gw_id
449
429
  if is_historical_review:
450
- console.print("\n[bold]## League[/bold]")
451
- console.print(f"[dim]League standings not shown for historical GW{gw} review[/dim]")
452
- console.print("[dim]Use 'fpl league' for current standings[/dim]")
430
+ print_historical_league_notice(gw)
453
431
  return {"league_name": league_name}
454
432
 
455
433
  # FPL builds a mini-league's table for the first time only once the
@@ -457,12 +435,11 @@ async def _review_classic_league(
457
435
  # `new_entries` and `results` is empty. Rendering the sections anyway
458
436
  # printed bare headings and handed the report a 0-point, 0-entry league.
459
437
  if not standings:
460
- console.print("\n[bold]## League[/bold]")
461
- console.print(
438
+ error_console.print(
462
439
  f"[dim]{rich_escape(league_name)}: standings not published yet"
463
440
  " -- FPL builds mini-league tables after the opening gameweek is finalised[/dim]"
464
441
  )
465
- console.print("[dim]Re-run once the table appears, or use 'fpl league'[/dim]")
442
+ error_console.print("[dim]Re-run once the table appears, or use 'fpl league'[/dim]")
466
443
  return {"league_name": league_name, "standings_pending": True}
467
444
 
468
445
  console.print("\n[bold]## League[/bold]")
@@ -540,54 +517,20 @@ async def _review_classic_league(
540
517
  client, standings, entry_id, gw, fetch_costs=use_net_points,
541
518
  )
542
519
 
543
- # Best GW performers (top 3 + ties)
544
- header_suffix = " (Net Points)" if use_net_points else ""
545
- sorted_by_net_desc = sorted(standings_with_costs, key=lambda x: x["net_points"], reverse=True)
546
- _assign_tie_ranks(sorted_by_net_desc, "net_points")
547
-
548
- # Snapshot best performer report data before ascending sort overwrites ranks
549
- best_performers_for_report = [
550
- _performer_row(e) for e in _slice_with_ties(sorted_by_net_desc, 3)
551
- ]
552
-
553
- console.print(f"\n[bold]### Best GW Performers{header_suffix}[/bold]")
554
- for perf in best_performers_for_report:
555
- _print_performer(perf)
556
-
557
- # Capture user's GW rank within the classic league (by net points)
558
- user_gw_entry = next(
559
- (e for e in sorted_by_net_desc if e["is_user"]), None
560
- )
561
- classic_user_gw_rank = user_gw_entry["rank_str"] if user_gw_entry else None
562
- # One page of standings is 50 entries; past that the ranks above
563
- # cover only the page fetched, so the report must not name a field
564
- # size that reads as the whole league
520
+ # One page of standings is 50 entries; past that the GW ranks cover
521
+ # only the page fetched (see `classic_gw_performers`)
565
522
  standings_complete = not standings_data.get("standings", {}).get("has_next", False)
566
- gw_field_size = len(sorted_by_net_desc) if standings_complete else None
567
-
568
- # Worst performers (bottom 5 + ties)
569
- sorted_by_net_asc = sorted(standings_with_costs, key=lambda x: x["net_points"])
570
- _assign_tie_ranks(sorted_by_net_asc, "net_points")
571
-
572
- worst_performers_data = _slice_with_ties(sorted_by_net_asc, 5)
573
- user_in_bottom = any(p["is_user"] for p in worst_performers_data)
523
+ performers = classic_gw_performers(standings_with_costs, complete=standings_complete)
574
524
  user_entry_data = next((p for p in standings_with_costs if p["is_user"]), None)
575
- # The user's own row when they sit above the bottom five, shown beside
576
- # the table rather than in it: a numbered row there read as a bottom
577
- # placing (#360). Its `rank_str` is the ascending one, so it is
578
- # labelled with the GW rank instead.
579
- user_context_row = (
580
- _performer_row(user_entry_data) if user_entry_data and not user_in_bottom else None
581
- )
582
525
 
583
526
  # Calculate transfer impact narrative (only when net points are tracked)
584
527
  transfer_impact = None
585
- if use_net_points and len(worst_performers_data) >= 2:
586
- last_place = worst_performers_data[0]
528
+ if use_net_points and len(performers.worst) >= 2:
529
+ last_place = performers.worst[0]
587
530
 
588
531
  if user_entry_data:
589
532
  user_transfer_cost = user_entry_data["transfer_cost"]
590
- user_is_last = user_entry_data == last_place
533
+ user_is_last = last_place["is_user"]
591
534
  if user_is_last and user_transfer_cost > 0:
592
535
  sorted_by_gross = sorted(standings_with_costs, key=lambda x: x["gross_points"])
593
536
  user_gross_rank = sorted_by_gross.index(user_entry_data)
@@ -600,13 +543,7 @@ async def _review_classic_league(
600
543
  f"{last_place['name']}'s -{last_place['transfer_cost']} hit saved you from last place"
601
544
  )
602
545
 
603
- worst_performers_for_report = [_performer_row(e) for e in worst_performers_data]
604
-
605
- console.print(f"\n[bold]### Worst GW Performers{header_suffix}[/bold]")
606
- for perf in worst_performers_for_report:
607
- _print_performer(perf)
608
- if user_context_row:
609
- console.print(f" {your_gw_rank_line(classic_user_gw_rank, gw_field_size, user_context_row)}")
546
+ _print_gw_performers(performers, header_suffix=" (Net Points)" if use_net_points else "")
610
547
 
611
548
  if transfer_impact:
612
549
  error_console.print(f"\n[yellow] ⚠ {rich_escape(transfer_impact)}[/yellow]")
@@ -615,8 +552,8 @@ async def _review_classic_league(
615
552
  classic_league_data = {
616
553
  "league_name": league_name,
617
554
  "user_position": user_rank,
618
- "user_gw_rank": classic_user_gw_rank,
619
- "gw_field_size": gw_field_size,
555
+ "user_gw_rank": performers.user_gw_rank,
556
+ "gw_field_size": performers.field_size,
620
557
  "total_entries": total_entries,
621
558
  "user_gw_points": user_gw_pts,
622
559
  "user_total": user_total,
@@ -635,9 +572,9 @@ async def _review_classic_league(
635
572
  ],
636
573
  "nearby_rivals_omitted": nearby_omitted,
637
574
  "use_net_points": use_net_points,
638
- "best_performers": best_performers_for_report,
639
- "worst_performers": worst_performers_for_report,
640
- "user_context_row": user_context_row,
575
+ "best_performers": performers.best,
576
+ "worst_performers": performers.worst,
577
+ "user_context_row": performers.user_context_row,
641
578
  "transfer_impact": transfer_impact,
642
579
  }
643
580
  if use_net_points and user_entry_data:
@@ -11,12 +11,13 @@ from rich.table import Table
11
11
 
12
12
  from fpl_cli.cli._context import console, error_console
13
13
  from fpl_cli.cli._helpers import (
14
- _assign_tie_ranks,
15
14
  _format_pts_display,
16
15
  _format_review_player,
17
16
  _live_player_stats,
18
17
  _net_transfer_ids,
19
- _slice_with_ties,
18
+ _print_gw_performers,
19
+ draft_gw_performers,
20
+ print_historical_league_notice,
20
21
  signed_net_markup,
21
22
  )
22
23
  from fpl_cli.models.player import POSITION_MAP
@@ -444,9 +445,7 @@ async def _review_draft(
444
445
  # ## League section - only show for current GW (live data)
445
446
  is_historical_review = api_current_gw_id is not None and gw != api_current_gw_id
446
447
  if is_historical_review:
447
- console.print("\n[bold]## League[/bold]")
448
- console.print(f"[dim]League standings not shown for historical GW{gw} review[/dim]")
449
- console.print("[dim]Use 'fpl league' for current standings[/dim]")
448
+ print_historical_league_notice(gw)
450
449
  else:
451
450
  console.print("\n[bold]## League[/bold]")
452
451
  console.print(f"- Position: {user_rank} of {total_entries}")
@@ -468,71 +467,31 @@ async def _review_draft(
468
467
  "event_total": s.get("event_total", 0),
469
468
  })
470
469
 
471
- # Best GW performers in draft league (top 3 + ties)
472
- sorted_by_gw = sorted(standings_with_names, key=lambda x: x["event_total"], reverse=True)
473
- _assign_tie_ranks(sorted_by_gw, "event_total")
474
- best_gw_display = _slice_with_ties(sorted_by_gw, 3)
475
-
476
- console.print("\n[bold]### Best GW Performers[/bold]")
477
- for entry in best_gw_display:
478
- name = entry["manager_name"]
479
- gw_pts = entry["event_total"]
480
- rank = entry["rank_str"]
481
- is_user = entry["entry_id"] == draft_entry_id
482
- if is_user:
483
- console.print(f" {rank}. [bold cyan]You[/bold cyan] - {gw_pts} pts")
484
- else:
485
- console.print(f" {rank}. {rich_escape(name)} - {gw_pts} pts")
486
-
487
- # Snapshot best data and user GW rank before worst sort overwrites ranks
488
- best_for_report = [
489
- {"name": e["manager_name"], "points": e["event_total"], "rank_str": e["rank_str"]}
490
- for e in best_gw_display
491
- ]
492
- user_entry = next(
493
- (e for e in sorted_by_gw if e["entry_id"] == draft_entry_id),
494
- None,
495
- )
496
- user_gw_rank = user_entry["rank_str"] if user_entry else None
497
-
498
- # Worst performers: sorted ascending (bottom 3 + ties)
499
- worst_sorted = sorted(standings_with_names, key=lambda x: x["event_total"])
500
- _assign_tie_ranks(worst_sorted, "event_total")
501
- worst_gw_display = _slice_with_ties(worst_sorted, 3)
502
-
503
- console.print("\n[bold]### Worst GW Performers[/bold]")
504
- for entry in worst_gw_display:
505
- name = entry["manager_name"]
506
- gw_pts = entry["event_total"]
507
- rank = entry["rank_str"]
508
- is_user = entry["entry_id"] == draft_entry_id
509
- if is_user:
510
- console.print(f" {rank}. [bold cyan]You[/bold cyan] - {gw_pts} pts")
511
- else:
512
- console.print(f" {rank}. {rich_escape(name)} - {gw_pts} pts")
470
+ # Top and bottom three plus ties, ranked and printed by the
471
+ # code `fpl league` uses (#381)
472
+ performers = draft_gw_performers(standings_with_names, draft_entry_id)
473
+ _print_gw_performers(performers)
513
474
 
514
475
  # Store for report (worst_performers sorted ascending - lowest first)
515
476
  draft_league_data = {
516
477
  "league_name": draft_league_name,
517
478
  "user_position": user_rank,
518
- "user_gw_rank": user_gw_rank,
479
+ "user_gw_rank": performers.user_gw_rank,
519
480
  "total_entries": total_entries,
520
481
  "user_gw_points": user_gw_pts,
521
482
  "user_total": user_total,
522
- "best_performers": best_for_report,
523
- "worst_performers": [
524
- {
525
- "name": e["manager_name"],
526
- "points": e["event_total"],
527
- "rank_str": e["rank_str"],
528
- "is_user": e["entry_id"] == draft_entry_id,
529
- }
530
- for e in worst_gw_display
531
- ],
483
+ "best_performers": performers.best,
484
+ "worst_performers": performers.worst,
532
485
  }
533
486
 
534
487
  elif not draft_entry_id:
535
- console.print("[dim]Set draft_entry_id in config/settings.yaml to see your draft squad[/dim]")
488
+ error_console.print("[dim]Set draft_entry_id in config/settings.yaml to see your draft squad[/dim]")
489
+ else:
490
+ error_console.print(
491
+ f"[yellow]Could not find draft_entry_id {draft_entry_id} in the standings of"
492
+ f" {rich_escape(draft_league_name)} -- check draft_entry_id and draft_league_id"
493
+ " in config/settings.yaml[/yellow]"
494
+ )
536
495
 
537
496
  except Exception as e: # noqa: BLE001 — display resilience
538
497
  error_console.print(f"[yellow]Could not fetch draft league data: {rich_escape(str(e))}[/yellow]")