vlrdevapi 2.1.0__tar.gz → 2.2.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 (200) hide show
  1. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.gitattributes +5 -1
  2. vlrdevapi-2.2.0/.github/workflows/cleanup-caches.yml +31 -0
  3. vlrdevapi-2.2.0/.github/workflows/docs-check.yml +102 -0
  4. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.gitignore +2 -0
  5. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/CHANGELOG.md +19 -1
  6. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/PKG-INFO +1 -1
  7. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/pyproject.toml +1 -1
  8. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/scripts/check_mdx_examples.py +287 -46
  9. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/__init__.py +1 -1
  10. vlrdevapi-2.2.0/src/vlrdevapi/_series/_utils.py +134 -0
  11. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/economy/namespace.py +3 -2
  12. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/economy/parser.py +10 -3
  13. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/match_namespace.py +4 -2
  14. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/performance/models.py +3 -3
  15. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/performance/namespace.py +12 -6
  16. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/performance/parser.py +47 -19
  17. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/players/namespace.py +2 -1
  18. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/players/parser.py +5 -3
  19. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/rounds/namespace.py +3 -2
  20. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/rounds/parser.py +10 -2
  21. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/uv.lock +1 -1
  22. vlrdevapi-2.1.0/.github/workflows/docs-check.yml +0 -29
  23. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.github/workflows/publish.yml +0 -0
  26. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.github/workflows/test.yml +0 -0
  27. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.python-version +0 -0
  28. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/.readthedocs.yaml +0 -0
  29. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/CODE_OF_CONDUCT.md +0 -0
  30. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/CONTRIBUTING.md +0 -0
  31. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/LICENCE +0 -0
  32. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/README.md +0 -0
  33. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/SECURITY.md +0 -0
  34. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/SUPPORT.md +0 -0
  35. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/scripts/download_fixtures.py +0 -0
  36. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/__init__.pyi +0 -0
  37. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_base.py +0 -0
  38. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_cache.py +0 -0
  39. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_client.py +0 -0
  40. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/__init__.py +0 -0
  41. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/_common.py +0 -0
  42. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/info/__init__.py +0 -0
  43. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/info/models.py +0 -0
  44. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/info/namespace.py +0 -0
  45. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/info/namespace.pyi +0 -0
  46. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/info/parser.py +0 -0
  47. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/list/__init__.py +0 -0
  48. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/list/models.py +0 -0
  49. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/list/namespace.py +0 -0
  50. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/list/namespace.pyi +0 -0
  51. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/list/parser.py +0 -0
  52. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/matches/__init__.py +0 -0
  53. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/matches/models.py +0 -0
  54. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/matches/namespace.py +0 -0
  55. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/matches/namespace.pyi +0 -0
  56. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/matches/parser.py +0 -0
  57. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/namespace.py +0 -0
  58. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/namespace.pyi +0 -0
  59. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/stages/__init__.py +0 -0
  60. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/stages/models.py +0 -0
  61. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/stages/namespace.py +0 -0
  62. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/stages/namespace.pyi +0 -0
  63. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/stages/parser.py +0 -0
  64. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/standings/__init__.py +0 -0
  65. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/standings/models.py +0 -0
  66. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/standings/namespace.py +0 -0
  67. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/standings/namespace.pyi +0 -0
  68. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/standings/parser.py +0 -0
  69. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/teams/__init__.py +0 -0
  70. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/teams/models.py +0 -0
  71. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/teams/namespace.py +0 -0
  72. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/teams/namespace.pyi +0 -0
  73. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_event/teams/parser.py +0 -0
  74. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/__init__.py +0 -0
  75. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/common.py +0 -0
  76. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/completed/__init__.py +0 -0
  77. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/completed/models.py +0 -0
  78. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/completed/namespace.py +0 -0
  79. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/completed/namespace.pyi +0 -0
  80. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/completed/parser.py +0 -0
  81. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/live/__init__.py +0 -0
  82. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/live/models.py +0 -0
  83. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/live/namespace.py +0 -0
  84. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/live/namespace.pyi +0 -0
  85. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/live/parser.py +0 -0
  86. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/namespace.py +0 -0
  87. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/namespace.pyi +0 -0
  88. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/upcoming/__init__.py +0 -0
  89. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/upcoming/models.py +0 -0
  90. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/upcoming/namespace.py +0 -0
  91. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/upcoming/namespace.pyi +0 -0
  92. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_matches/upcoming/parser.py +0 -0
  93. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/__init__.py +0 -0
  94. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/_common_parsing.py +0 -0
  95. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/agents/__init__.py +0 -0
  96. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/agents/models.py +0 -0
  97. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/agents/namespace.py +0 -0
  98. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/agents/namespace.pyi +0 -0
  99. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/agents/parser.py +0 -0
  100. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/info/__init__.py +0 -0
  101. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/info/models.py +0 -0
  102. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/info/namespace.py +0 -0
  103. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/info/namespace.pyi +0 -0
  104. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/info/parser.py +0 -0
  105. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/matches/__init__.py +0 -0
  106. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/matches/models.py +0 -0
  107. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/matches/namespace.py +0 -0
  108. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/matches/namespace.pyi +0 -0
  109. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/matches/parser.py +0 -0
  110. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/namespace.py +0 -0
  111. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/namespace.pyi +0 -0
  112. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/profile/__init__.py +0 -0
  113. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/profile/models.py +0 -0
  114. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/profile/namespace.py +0 -0
  115. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/profile/namespace.pyi +0 -0
  116. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/profile/parser.py +0 -0
  117. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/teams/__init__.py +0 -0
  118. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/teams/models.py +0 -0
  119. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/teams/namespace.py +0 -0
  120. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/teams/namespace.pyi +0 -0
  121. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_player/teams/parser.py +0 -0
  122. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/__init__.py +0 -0
  123. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/economy/__init__.py +0 -0
  124. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/economy/models.py +0 -0
  125. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/economy/namespace.pyi +0 -0
  126. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/info/__init__.py +0 -0
  127. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/info/models.py +0 -0
  128. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/info/namespace.py +0 -0
  129. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/info/namespace.pyi +0 -0
  130. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/info/parser.py +0 -0
  131. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/match_namespace.pyi +0 -0
  132. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/namespace.py +0 -0
  133. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/namespace.pyi +0 -0
  134. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/performance/__init__.py +0 -0
  135. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/performance/namespace.pyi +0 -0
  136. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/players/__init__.py +0 -0
  137. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/players/models.py +0 -0
  138. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/players/namespace.pyi +0 -0
  139. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/rounds/__init__.py +0 -0
  140. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/rounds/models.py +0 -0
  141. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/rounds/namespace.pyi +0 -0
  142. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/vods/__init__.py +0 -0
  143. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/vods/models.py +0 -0
  144. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/vods/namespace.py +0 -0
  145. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/vods/namespace.pyi +0 -0
  146. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_series/vods/parser.py +0 -0
  147. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/__init__.py +0 -0
  148. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/completed_matches/__init__.py +0 -0
  149. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/completed_matches/models.py +0 -0
  150. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/completed_matches/namespace.py +0 -0
  151. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/completed_matches/namespace.pyi +0 -0
  152. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/completed_matches/parser.py +0 -0
  153. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/info/__init__.py +0 -0
  154. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/info/models.py +0 -0
  155. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/info/namespace.py +0 -0
  156. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/info/namespace.pyi +0 -0
  157. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/info/parser.py +0 -0
  158. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/namespace.py +0 -0
  159. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/namespace.pyi +0 -0
  160. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/placements/__init__.py +0 -0
  161. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/placements/models.py +0 -0
  162. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/placements/namespace.py +0 -0
  163. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/placements/namespace.pyi +0 -0
  164. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/placements/parser.py +0 -0
  165. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/roster/__init__.py +0 -0
  166. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/roster/models.py +0 -0
  167. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/roster/namespace.py +0 -0
  168. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/roster/namespace.pyi +0 -0
  169. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/roster/parser.py +0 -0
  170. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/stats/__init__.py +0 -0
  171. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/stats/models.py +0 -0
  172. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/stats/namespace.py +0 -0
  173. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/stats/namespace.pyi +0 -0
  174. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/stats/parser.py +0 -0
  175. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/transactions/__init__.py +0 -0
  176. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/transactions/models.py +0 -0
  177. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/transactions/namespace.py +0 -0
  178. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/transactions/namespace.pyi +0 -0
  179. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/transactions/parser.py +0 -0
  180. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/upcoming_matches/__init__.py +0 -0
  181. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/upcoming_matches/models.py +0 -0
  182. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/upcoming_matches/namespace.py +0 -0
  183. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/upcoming_matches/namespace.pyi +0 -0
  184. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_team/upcoming_matches/parser.py +0 -0
  185. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_utils/__init__.py +0 -0
  186. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_utils/pagination.py +0 -0
  187. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_utils/paths.py +0 -0
  188. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_utils/team_enrichment.py +0 -0
  189. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/_utils/team_parsing.py +0 -0
  190. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/commons/__init__.py +0 -0
  191. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/commons/countries.py +0 -0
  192. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/commons/datetime.py +0 -0
  193. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/commons/mappings.py +0 -0
  194. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/commons/prizes.py +0 -0
  195. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/commons/timezone.py +0 -0
  196. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/exceptions.py +0 -0
  197. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/fetcher.py +0 -0
  198. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/py.typed +0 -0
  199. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/src/vlrdevapi/validators.py +0 -0
  200. {vlrdevapi-2.1.0 → vlrdevapi-2.2.0}/wrangler.jsonc +0 -0
@@ -4,4 +4,8 @@ docs-py/** linguist-vendored
4
4
 
5
5
  # Exclude test fixtures
6
6
  tests/test_html/** linguist-vendored
7
- tests/test_html/** linguist-detectable=false
7
+ tests/test_html/** linguist-detectable=false
8
+
9
+ # Normalize workflow YAML line endings
10
+ *.yml text eol=lf
11
+ *.yaml text eol=lf
@@ -0,0 +1,31 @@
1
+ name: Cleanup docs-check caches on closed PRs
2
+
3
+ on:
4
+ pull_request:
5
+ types:
6
+ - closed
7
+
8
+ permissions:
9
+ actions: write
10
+
11
+ jobs:
12
+ cleanup:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Delete PR caches
16
+ env:
17
+ GH_TOKEN: ${{ github.token }}
18
+ GH_REPO: ${{ github.repository }}
19
+ BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
20
+ run: |
21
+ echo "Fetching list of cache keys"
22
+ cacheKeysForPR=$(gh cache list --ref $BRANCH --limit 100 --json id --jq '.[].id')
23
+
24
+ ## Setting this to not fail the workflow while deleting cache keys.
25
+ set +e
26
+ echo "Deleting caches..."
27
+ for cacheKey in $cacheKeysForPR
28
+ do
29
+ gh cache delete $cacheKey
30
+ done
31
+ echo "Done"
@@ -0,0 +1,102 @@
1
+ name: Docs Check
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - 'official-docs/**'
7
+ - 'docs-py/**'
8
+ push:
9
+ branches: [main]
10
+ paths:
11
+ - 'official-docs/**'
12
+ - 'docs-py/**'
13
+
14
+ jobs:
15
+ check:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+
20
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
21
+ with:
22
+ python-version: '3.12'
23
+
24
+ - name: Install package
25
+ run: uv sync --group dev
26
+
27
+ # main always validates live against fresh vlr.gg data; never uses the
28
+ # cache. Branch/PR runs reuse fixtures cached by earlier runs in the
29
+ # same PR (within a 2h TTL), falling back to live fetches for anything
30
+ # missing, so the check is correct even with a partially-stale cache.
31
+ - name: Run MDX examples check (main, live)
32
+ if: github.ref_name == 'main'
33
+ run: uv run scripts/check_mdx_examples.py --live
34
+
35
+ # actions/cache v6.1.0, pinned to a reviewed commit SHA.
36
+ - name: Restore fixture cache (branch)
37
+ id: mdx-fixtures
38
+ if: github.ref_name != 'main'
39
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
40
+ with:
41
+ path: fixtures.tar.gz
42
+ key: mdx-fixtures-${{ github.ref_name }}-run-${{ github.run_id }}
43
+ restore-keys: |
44
+ mdx-fixtures-${{ github.ref_name }}-
45
+
46
+ - name: Unpack fixtures (branch)
47
+ if: github.ref_name != 'main'
48
+ run: |
49
+ if [ -f fixtures.tar.gz ]; then
50
+ mkdir -p .cache
51
+ tar -C .cache -xzf fixtures.tar.gz
52
+ fi
53
+
54
+ - name: Check fixture cache age (branch, 2h TTL)
55
+ id: cache-age
56
+ if: github.ref_name != 'main'
57
+ run: |
58
+ matched="${{ steps.mdx-fixtures.outputs.cache-matched-key }}"
59
+ now=$(date +%s)
60
+ if [ -n "$matched" ]; then
61
+ epoch="${matched##*-}"
62
+ age=$(( now - epoch ))
63
+ else
64
+ age=999999
65
+ fi
66
+ if [ "$age" -gt 7200 ]; then
67
+ rm -rf .cache/mdx-html
68
+ rm -f fixtures.tar.gz
69
+ mkdir -p .cache/mdx-html
70
+ echo "fresh=false" >> "$GITHUB_OUTPUT"
71
+ else
72
+ echo "fresh=true" >> "$GITHUB_OUTPUT"
73
+ fi
74
+ echo "now=$now" >> "$GITHUB_OUTPUT"
75
+
76
+ - name: Run MDX examples check (branch)
77
+ if: github.ref_name != 'main'
78
+ run: |
79
+ if [ "${{ steps.cache-age.outputs.fresh }}" == "true" ]; then
80
+ uv run scripts/check_mdx_examples.py --skip-warm
81
+ else
82
+ uv run scripts/check_mdx_examples.py
83
+ fi
84
+
85
+ # always(): a run that fails validation still saved its warm fixtures,
86
+ # so the next fix commit restores and validates fast instead of warming.
87
+ - name: Package fixtures (branch)
88
+ if: always() && github.ref_name != 'main' && steps.cache-age.outputs.fresh == 'false'
89
+ run: |
90
+ mkdir -p .cache/mdx-html
91
+ if [ -n "$(ls -A .cache/mdx-html)" ]; then
92
+ tar -C .cache -czf fixtures.tar.gz mdx-html
93
+ else
94
+ rm -f fixtures.tar.gz
95
+ fi
96
+
97
+ - name: Save fixture cache (branch)
98
+ if: always() && github.ref_name != 'main' && steps.cache-age.outputs.fresh == 'false'
99
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
100
+ with:
101
+ path: fixtures.tar.gz
102
+ key: mdx-fixtures-${{ github.ref_name }}-${{ steps.cache-age.outputs.now }}
@@ -9,6 +9,8 @@ wheels/
9
9
  # Virtual environments
10
10
  .venv
11
11
  tests/test_html/
12
+ .cache/mdx-html/
13
+ fixtures.tar.gz
12
14
 
13
15
  nul
14
16
  .pytest_cache/
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
- ## [Unreleased]
8
+ ## [2.2.0] - 2026-08-11
9
9
 
10
10
  ### Changed
11
11
 
@@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
14
14
  sentinel year of `2019` instead of the current year. Any parsed date with
15
15
  `year < 2020` means the source omitted the year, so guard with
16
16
  `event.start_date.year < 2020` before relying on it.
17
+ - **Series performance field renames (breaking)** — `AdvStatsEntry` fields
18
+ were renamed to descriptive names: `econ` → `economy`, `pl` → `plants`,
19
+ and `de` → `defuses`. Update any code referencing the old field names.
20
+ The series performance reference docs were updated to match.
17
21
 
18
22
  ### Fixed
19
23
 
@@ -22,6 +26,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
22
26
  change behavior in Python 3.15). All such parses now supply an explicit
23
27
  year derived from context (reference date, page-header year, or the
24
28
  sentinel above).
29
+ - **Series per-game stats by game number** — `players()`, `rounds()`,
30
+ `performance()`, and `economy()` game identifiers can now be a 1-based
31
+ game number within the series, resolved to the real VLR game ID from the
32
+ page's game nav tabs. A real VLR game ID is still accepted unchanged;
33
+ previously passing a game number produced empty results.
34
+ - **Series economy and rounds scope** — economy and round-by-round data are
35
+ now parsed from the selected game's section only. The economy parser
36
+ previously collected tables from the whole page, so a per-game request
37
+ could include data from other maps.
38
+ - **Series performance player IDs** — the performance tab renders player
39
+ cells without links, leaving player IDs as `0`. IDs are now recovered from
40
+ the series overview tab (keyed by team abbreviation and player name) and
41
+ attached to kill-matrix entries, advanced stats, and notable-round victims.
25
42
 
26
43
  ## [2.1.0] - 2026-08-07
27
44
 
@@ -125,6 +142,7 @@ The API surface, module structure, and type system are all new.
125
142
  - `aiohttp` and `asyncio` dependencies.
126
143
  - v1.x match/event/team parsing modules.
127
144
 
145
+ [2.2.0]: https://github.com/Vanshbordia/vlrdevapi/compare/v2.1.0...v2.2.0
128
146
  [2.1.0]: https://github.com/Vanshbordia/vlrdevapi/compare/v2.0.1...v2.1.0
129
147
  [2.0.1]: https://github.com/Vanshbordia/vlrdevapi/compare/v2.0.0...v2.0.1
130
148
  [2.0.0]: https://github.com/Vanshbordia/vlrdevapi/compare/v1.6.2...v2.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vlrdevapi
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: A Python library to scrape data from vlr.gg
5
5
  Project-URL: Homepage, https://vlrdevapi.pages.dev/
6
6
  Project-URL: Repository, https://github.com/Vanshbordia/vlrdevapi
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vlrdevapi"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "A Python library to scrape data from vlr.gg"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,31 +2,44 @@
2
2
 
3
3
  Extracts every Python code block from official-docs/content/ (docs,
4
4
  blog, guides, etc.), checks syntax validity, import resolution, then
5
- executes examples against the live vlr.gg API.
5
+ executes examples. An ``httpx.Client.get`` interceptor de-duplicates
6
+ fetches within a run and can replay validation offline against HTML
7
+ fixtures saved under ``.cache/mdx-html/`` (gitignored).
6
8
 
7
9
  CI/CD usage:
8
- python scripts/check_mdx_examples.py # syntax + imports + run
9
- python scripts/check_mdx_examples.py --timeout 60 # custom per-example timeout
10
+ python scripts/check_mdx_examples.py # warm + offline replay
11
+ python scripts/check_mdx_examples.py --skip-warm # offline replay only
12
+ python scripts/check_mdx_examples.py --live # single live pass
13
+ python scripts/check_mdx_examples.py --timeout 60 # custom per-example timeout
10
14
  """
11
15
 
12
16
  from __future__ import annotations
13
17
 
14
18
  import ast
15
19
  import asyncio
20
+ import hashlib
16
21
  import importlib
17
22
  import io
18
23
  import os
19
24
  import re
20
25
  import sys
21
26
  import textwrap
27
+ import threading
22
28
  import time
23
29
  import traceback
24
- from contextlib import redirect_stdout, redirect_stderr
30
+ from contextlib import redirect_stderr, redirect_stdout
25
31
  from pathlib import Path
26
32
  from typing import NamedTuple
27
33
 
34
+ import httpx
35
+
36
+ from vlrdevapi.fetcher import DEFAULT_RATE_LIMIT, RateLimiter
37
+
28
38
  REPO_ROOT = Path(__file__).resolve().parent.parent
29
39
  DOCS_DIR = REPO_ROOT / "official-docs" / "content"
40
+ DEFAULT_FIXTURES_DIR = REPO_ROOT / ".cache" / "mdx-html"
41
+
42
+ DYNAMIC_PATHS = frozenset({"/matches", "/matches/results", "/events"})
30
43
 
31
44
 
32
45
  class Result(NamedTuple):
@@ -41,6 +54,142 @@ class Result(NamedTuple):
41
54
  duration: float
42
55
 
43
56
 
57
+ # ---------------------------------------------------------------------------
58
+ # HTTP cache (dedup + offline replay)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ class HTTPCache:
62
+ """Thread-safe HTTP response cache that intercepts ``httpx.Client.get``.
63
+
64
+ Patched at the class level so every library request — sync, parallel
65
+ worker threads, enrichment, and the dark-mode team info double fetch —
66
+ funnels through it (all go via ``fetcher.fetch_sync`` ->
67
+ ``client.get``). Keys are sha256 of the resolved URL + sorted headers,
68
+ so the same path fetched with a different ``Cookie`` header (dark mode)
69
+ gets a distinct cache entry.
70
+
71
+ Modes:
72
+ record: disk hit -> reuse (no network); miss -> live fetch + cache.
73
+ replay: hit -> reuse; miss -> live fallback (no disk writes).
74
+ live: in-memory only; miss -> live fetch + memory cache.
75
+
76
+ Time-varying listing pages (``/matches``, ``/matches/results``,
77
+ ``/events``) are treated as dynamic: always fetched live, deduplicated
78
+ in memory only, and never persisted to disk. Serving a stale cached
79
+ fixture for these could silently validate examples against outdated
80
+ match data.
81
+ """
82
+
83
+ def __init__(self, fixtures_dir: Path | None, mode: str) -> None:
84
+ self.fixtures_dir = fixtures_dir
85
+ self.mode = mode
86
+ self._lock = threading.Lock()
87
+ self._mem: dict[str, str] = {}
88
+ self._orig_get = None
89
+ self.total_requests = 0
90
+ self.live_fetches = 0
91
+ self.hits_mem = 0
92
+ self.hits_disk = 0
93
+ self.misses = 0
94
+ self.live_fallbacks = 0
95
+ self.unique_pages: set[str] = set()
96
+
97
+ def install(self) -> None:
98
+ """Patch ``httpx.Client.get`` at the class level."""
99
+ if self._orig_get is None:
100
+ self._orig_get = httpx.Client.get
101
+ cache = self
102
+ orig_get = self._orig_get
103
+
104
+ def _intercept(client: httpx.Client, url: str, **kwargs) -> httpx.Response:
105
+ return cache._handle(client, url, orig_get, **kwargs)
106
+
107
+ httpx.Client.get = _intercept # type: ignore[method-assign]
108
+
109
+ def restore(self) -> None:
110
+ """Restore the original ``httpx.Client.get``."""
111
+ if self._orig_get is not None:
112
+ httpx.Client.get = self._orig_get # type: ignore[method-assign]
113
+ self._orig_get = None
114
+
115
+ def _key(self, request: httpx.Request, headers: dict[str, str] | None) -> str:
116
+ header_items = sorted((headers or {}).items())
117
+ payload = f"{request.url}|{header_items!r}".encode()
118
+ return hashlib.sha256(payload).hexdigest()
119
+
120
+ def _lookup(self, key: str, allow_disk: bool = True) -> str | None:
121
+ with self._lock:
122
+ html = self._mem.get(key)
123
+ if html is not None:
124
+ self.hits_mem += 1
125
+ return html
126
+ if allow_disk and self.fixtures_dir is not None:
127
+ fixture = self.fixtures_dir / f"{key}.html"
128
+ if fixture.exists():
129
+ try:
130
+ html = fixture.read_text(encoding="utf-8")
131
+ except OSError:
132
+ html = None
133
+ if html is not None:
134
+ self._mem[key] = html
135
+ self.hits_disk += 1
136
+ return html
137
+ return None
138
+
139
+ def _store(self, key: str, html: str, to_disk: bool = True) -> None:
140
+ with self._lock:
141
+ self._mem[key] = html
142
+ if to_disk and self.mode == "record" and self.fixtures_dir is not None:
143
+ try:
144
+ self.fixtures_dir.mkdir(parents=True, exist_ok=True)
145
+ fixture = self.fixtures_dir / f"{key}.html"
146
+ if not fixture.exists():
147
+ fixture.write_text(html, encoding="utf-8")
148
+ except OSError:
149
+ pass
150
+
151
+ def _throttle(self) -> None:
152
+ if DEFAULT_RATE_LIMIT > 0:
153
+ time.sleep(1.0 / DEFAULT_RATE_LIMIT)
154
+
155
+ def _handle(
156
+ self,
157
+ client: httpx.Client,
158
+ url: str,
159
+ orig_get,
160
+ **kwargs,
161
+ ) -> httpx.Response:
162
+ self.total_requests += 1
163
+ headers = kwargs.get("headers")
164
+ request = client.build_request("GET", url, headers=headers)
165
+ key = self._key(request, headers)
166
+ self.unique_pages.add(key)
167
+ dynamic = request.url.path in DYNAMIC_PATHS
168
+
169
+ cached = self._lookup(key, allow_disk=not dynamic)
170
+ if cached is not None:
171
+ return httpx.Response(200, text=cached, request=request)
172
+
173
+ self.misses += 1
174
+ if self.mode == "replay":
175
+ self.live_fallbacks += 1
176
+ self._throttle()
177
+ self.live_fetches += 1
178
+ resp = orig_get(client, url, **kwargs)
179
+ if resp.status_code == 200 and self.mode in ("record", "live"):
180
+ self._store(key, resp.text, to_disk=not dynamic)
181
+ return resp
182
+
183
+
184
+ class RunStats:
185
+ """Durations for each phase, reported in the summary footer."""
186
+
187
+ def __init__(self) -> None:
188
+ self.phase1 = 0.0
189
+ self.warm = 0.0
190
+ self.replay = 0.0
191
+
192
+
44
193
  # ---------------------------------------------------------------------------
45
194
  # Extractors
46
195
  # ---------------------------------------------------------------------------
@@ -96,10 +245,7 @@ def is_signature_block(code: str) -> bool:
96
245
 
97
246
  # Last line: ")" or ") -> ReturnType"
98
247
  last = lines[-1].strip()
99
- if not re.match(r"^\s*\)\s*(->\s*\S+)?\s*$", last):
100
- return False
101
-
102
- return True
248
+ return bool(re.match(r"^\s*\)\s*(->\s*\S+)?\s*$", last))
103
249
 
104
250
 
105
251
  def normalize_code(code: str) -> str:
@@ -190,7 +336,7 @@ async def exec_async(code: str, timeout: float) -> tuple[bool, str, float]:
190
336
  wrapped = "async def __example():\n" + textwrap.indent(code, " ")
191
337
 
192
338
  compiled = compile(wrapped, "<doc_example>", "exec")
193
- exec(compiled, namespace)
339
+ exec(compiled, namespace) # noqa: S102
194
340
  out = io.StringIO()
195
341
  err = io.StringIO()
196
342
  with redirect_stdout(out), redirect_stderr(err):
@@ -205,9 +351,9 @@ async def exec_async(code: str, timeout: float) -> tuple[bool, str, float]:
205
351
  output += "\n[stderr]\n" + err.getvalue().strip()
206
352
  return True, output, elapsed
207
353
 
208
- except asyncio.TimeoutError:
354
+ except TimeoutError:
209
355
  return False, f"TIMEOUT after {time.monotonic() - start:.1f}s", time.monotonic() - start
210
- except Exception as e:
356
+ except Exception as e: # noqa: BLE001
211
357
  tb = traceback.format_exc()
212
358
  return False, f"{type(e).__name__}: {e}\n{tb}", time.monotonic() - start
213
359
 
@@ -229,7 +375,7 @@ async def exec_sync(code: str, timeout: float) -> tuple[bool, str, float]:
229
375
  out = io.StringIO()
230
376
  err = io.StringIO()
231
377
  with redirect_stdout(out), redirect_stderr(err):
232
- exec(compiled, namespace)
378
+ exec(compiled, namespace) # noqa: S102
233
379
  return out.getvalue().strip() or "(no output)"
234
380
 
235
381
  loop = asyncio.get_running_loop()
@@ -241,9 +387,9 @@ async def exec_sync(code: str, timeout: float) -> tuple[bool, str, float]:
241
387
  if not ok:
242
388
  return False, msg, time.monotonic() - start
243
389
  return True, output, time.monotonic() - start
244
- except asyncio.TimeoutError:
390
+ except TimeoutError:
245
391
  return False, f"TIMEOUT after {time.monotonic() - start:.1f}s", time.monotonic() - start
246
- except Exception as e:
392
+ except Exception as e: # noqa: BLE001
247
393
  return False, f"{type(e).__name__}: {e}", time.monotonic() - start
248
394
 
249
395
 
@@ -255,12 +401,67 @@ def has_skip_marker(code: str) -> bool:
255
401
  return any(line.strip().startswith("# doc-check: skip") for line in code.splitlines())
256
402
 
257
403
 
258
- async def run_all(timeout: float) -> list[Result]:
404
+ async def execute_blocks(
405
+ results: list[Result],
406
+ timeout: float,
407
+ cache: HTTPCache,
408
+ label: str,
409
+ *,
410
+ record_results: bool,
411
+ ) -> None:
412
+ """Execute every runnable block against the current cache mode.
413
+
414
+ In warm mode (``record_results=False``) execution is best-effort: the
415
+ point is to populate the cache/fixtures and de-duplicate fetches, so
416
+ failures do not affect the run. In replay/live mode (``record_results=True``)
417
+ results are recorded and validated.
418
+ """
419
+ print(f"=== Phase: {label} ===")
420
+ for i, r in enumerate(results):
421
+ code = r.code
422
+
423
+ # Skip non-runnable blocks
424
+ if not is_runnable(code):
425
+ continue
426
+
427
+ # Skip user-marked blocks
428
+ if has_skip_marker(code):
429
+ continue
430
+
431
+ if is_async(code):
432
+ run_ok, run_msg, dur = await exec_async(code, timeout)
433
+ else:
434
+ run_ok, run_msg, dur = await exec_sync(code, timeout)
435
+
436
+ if record_results:
437
+ results[i] = Result(
438
+ r.file, r.code, r.syntax_ok, r.syntax_error, r.imports_ok,
439
+ r.import_errors, run_ok, run_msg, dur,
440
+ )
441
+ tag = "OK" if run_ok else "FAIL"
442
+ else:
443
+ tag = "warm-ok" if run_ok else "warm-err"
444
+
445
+ print(f" {tag} ({dur:.1f}s) {r.file}")
446
+ if record_results:
447
+ msg_short = run_msg[:200].replace("\n", "\\n")
448
+ print(f" -> {msg_short}")
449
+
450
+
451
+ async def run_all(
452
+ timeout: float,
453
+ cache: HTTPCache,
454
+ *,
455
+ skip_warm: bool,
456
+ live: bool,
457
+ ) -> tuple[list[Result], RunStats]:
458
+ stats = RunStats()
259
459
  results: list[Result] = []
260
460
 
261
461
  print("=== Phase 1: Syntax & Import Check ===")
462
+ phase1_start = time.monotonic()
262
463
  for path in sorted(all_doc_files()):
263
- with open(path, encoding="utf-8") as f:
464
+ with open(path, encoding="utf-8") as f: # noqa: ASYNC230
264
465
  text = f.read()
265
466
  blocks = extract_blocks(text)
266
467
  rel = os.path.relpath(path, REPO_ROOT)
@@ -297,37 +498,39 @@ async def run_all(timeout: float) -> list[Result]:
297
498
  results.append(Result(rel, code, True, "", True, [], None, "", 0.0))
298
499
  print(f" OK {rel}")
299
500
 
501
+ stats.phase1 = time.monotonic() - phase1_start
300
502
  print(f"\n >> Phase 1 complete: all {len(results)} block(s) OK\n")
301
503
 
302
- print(f"=== Phase 2: Live Execution ({timeout}s timeout per block) ===")
303
- for i, r in enumerate(results):
304
- code = r.code
305
-
306
- # Skip non-runnable blocks
307
- if not is_runnable(code):
308
- print(f" SKIP (no function call) {r.file}")
309
- continue
310
-
311
- # Skip user-marked blocks
312
- if has_skip_marker(code):
313
- print(f" SKIP (marker) {r.file}")
314
- continue
315
-
316
- if is_async(code):
317
- run_ok, run_msg, dur = await exec_async(code, timeout)
318
- else:
319
- run_ok, run_msg, dur = await exec_sync(code, timeout)
320
-
321
- results[i] = Result(r.file, r.code, r.syntax_ok, r.syntax_error, r.imports_ok, r.import_errors, run_ok, run_msg, dur)
322
- tag = "OK" if run_ok else "FAIL"
323
- print(f" {tag} ({dur:.1f}s) {r.file}")
324
- msg_short = run_msg[:200].replace("\n", "\\n")
325
- print(f" -> {msg_short}")
504
+ if live:
505
+ cache.mode = "live"
506
+ print(f"=== Live execution ({timeout}s timeout per block, in-memory dedup) ===")
507
+ replay_start = time.monotonic()
508
+ await execute_blocks(results, timeout, cache, "Live (validated)", record_results=True)
509
+ stats.replay = time.monotonic() - replay_start
510
+ return results, stats
511
+
512
+ if not skip_warm:
513
+ cache.mode = "record"
514
+ warm_start = time.monotonic()
515
+ await execute_blocks(results, timeout, cache, "Warm (record fixtures)", record_results=False)
516
+ stats.warm = time.monotonic() - warm_start
517
+ print(f"\n >> Warm phase complete: fixtures ready in {cache.fixtures_dir}\n")
518
+
519
+ cache.mode = "replay"
520
+ print(f"=== Replay execution ({timeout}s timeout per block, offline against fixtures) ===")
521
+ orig_acquire = RateLimiter.acquire
522
+ RateLimiter.acquire = lambda self: None # type: ignore[method-assign]
523
+ try:
524
+ replay_start = time.monotonic()
525
+ await execute_blocks(results, timeout, cache, "Replay (validated)", record_results=True)
526
+ stats.replay = time.monotonic() - replay_start
527
+ finally:
528
+ RateLimiter.acquire = orig_acquire # type: ignore[method-assign]
326
529
 
327
- return results
530
+ return results, stats
328
531
 
329
532
 
330
- def print_report(results: list[Result]) -> int:
533
+ def print_report(results: list[Result], cache: HTTPCache | None = None, stats: RunStats | None = None) -> int:
331
534
  run_attempts = [r for r in results if r.run_ok is not None]
332
535
  run_oks = sum(1 for r in run_attempts if r.run_ok is True)
333
536
  run_fails = [r for r in results if r.run_ok is False]
@@ -340,6 +543,23 @@ def print_report(results: list[Result]) -> int:
340
543
  print(f" EMPTY OUTPUT FAILURES: {no_output} (prints were silently skipped)")
341
544
  print(f"{'='*60}")
342
545
 
546
+ if cache is not None:
547
+ saved = cache.total_requests - cache.live_fetches
548
+ print("\nHTTP CACHE STATS:")
549
+ print(f" Requests intercepted: {cache.total_requests}")
550
+ print(f" Unique pages touched: {len(cache.unique_pages)}")
551
+ print(f" Live fetches: {cache.live_fetches} (dedup saved {max(saved, 0)})")
552
+ print(f" Cache hits: {cache.hits_mem} mem / {cache.hits_disk} disk")
553
+ print(f" Cache misses: {cache.misses}")
554
+ if cache.live_fallbacks:
555
+ print(f" Live fallbacks: {cache.live_fallbacks}")
556
+ if stats is not None:
557
+ print("\nPHASE DURATIONS:")
558
+ print(f" Phase 1 (static): {stats.phase1:.1f}s")
559
+ if stats.warm:
560
+ print(f" Warm (record): {stats.warm:.1f}s")
561
+ print(f" Replay/validate: {stats.replay:.1f}s")
562
+
343
563
  if run_fails:
344
564
  print(f"\nRUNTIME FAILURES ({len(run_fails)}):")
345
565
  for r in run_fails:
@@ -371,8 +591,26 @@ def main():
371
591
  parser = argparse.ArgumentParser(description="Check MDX doc examples")
372
592
  parser.add_argument("--timeout", type=float, default=60.0, help="Per-example timeout (s)")
373
593
  parser.add_argument("--log", type=str, default="", help="Path to save verbose output log")
594
+ parser.add_argument(
595
+ "--fixtures-dir", type=str, default=str(DEFAULT_FIXTURES_DIR),
596
+ help="Directory for cached HTML fixtures (default: .cache/mdx-html)",
597
+ )
598
+ parser.add_argument(
599
+ "--skip-warm", action="store_true",
600
+ help="Skip the warm (record) phase; replay against existing fixtures",
601
+ )
602
+ parser.add_argument(
603
+ "--live", action="store_true",
604
+ help="Single live pass with in-memory dedup (no disk fixtures)",
605
+ )
374
606
  args = parser.parse_args()
375
607
 
608
+ fixtures_dir = None
609
+ if not args.live and args.fixtures_dir:
610
+ fixtures_dir = Path(args.fixtures_dir)
611
+ cache = HTTPCache(fixtures_dir=fixtures_dir, mode="replay")
612
+ stats = RunStats()
613
+
376
614
  log_lines: list[str] = []
377
615
 
378
616
  # Override print in the check module's namespace
@@ -392,15 +630,18 @@ def main():
392
630
  builtins.print = tee_print # type: ignore
393
631
 
394
632
  try:
395
- results = asyncio.run(run_all(args.timeout))
396
- exit_code = print_report(results)
633
+ cache.install()
634
+ results, stats = asyncio.run(
635
+ run_all(args.timeout, cache, skip_warm=args.skip_warm, live=args.live)
636
+ )
637
+ exit_code = print_report(results, cache=cache, stats=stats)
397
638
  finally:
639
+ cache.restore()
398
640
  builtins.print = orig_print
399
641
 
400
642
  if args.log:
401
643
  with open(args.log, "w", encoding="utf-8") as f:
402
- for line in _log_lines:
403
- f.write(line + "\n")
644
+ f.writelines(line + "\n" for line in _log_lines)
404
645
  orig_print(f"\nLog saved to: {args.log}")
405
646
 
406
647
  sys.exit(exit_code)
@@ -45,7 +45,7 @@ if TYPE_CHECKING:
45
45
  matches: MatchesNamespace
46
46
  client: type[VLRClient]
47
47
 
48
- __version__ = "2.1.0"
48
+ __version__ = "2.2.0"
49
49
 
50
50
  _default_client: "VLRClient | None" = None
51
51