nab-python 0.0.4__tar.gz → 0.0.5__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 (179) hide show
  1. {nab_python-0.0.4 → nab_python-0.0.5}/PKG-INFO +3 -3
  2. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/canary.py +3 -1
  3. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios.py +6 -1
  4. {nab_python-0.0.4 → nab_python-0.0.5}/pyproject.toml +3 -3
  5. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_lockfile/pylock.py +66 -1
  6. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_lockfile/requirements.py +13 -5
  7. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/extras.py +33 -24
  8. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/listing.py +19 -12
  9. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/metadata_resolver.py +5 -17
  10. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/priority.py +3 -1
  11. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vcs_admission.py +7 -6
  12. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/download.py +4 -2
  13. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/fetch.py +33 -6
  14. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/lockfile.py +2 -1
  15. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/metadata.py +24 -2
  16. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/provider.py +11 -12
  17. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/resolve.py +2 -4
  18. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/universal/matrix.py +11 -4
  19. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/universal/reresolve.py +11 -1
  20. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/universal/validate.py +49 -11
  21. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/universal/wheel_selection.py +22 -2
  22. nab_python-0.0.5/tests/property_python/test_build_policy_never.py +151 -0
  23. nab_python-0.0.5/tests/property_python/test_download_hashes.py +123 -0
  24. nab_python-0.0.5/tests/property_python/test_fetch_coordinator.py +262 -0
  25. nab_python-0.0.5/tests/property_python/test_vcs_admission.py +193 -0
  26. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_cached_client.py +8 -0
  27. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_config.py +14 -0
  28. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_download.py +30 -0
  29. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_fetch.py +106 -2
  30. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_local_index.py +24 -0
  31. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_lockfile.py +124 -0
  32. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_metadata.py +22 -0
  33. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_provider.py +260 -39
  34. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_resolve.py +9 -22
  35. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_resolver_packaging.py +18 -0
  36. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_vcs_admission.py +17 -0
  37. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/test_matrix.py +10 -0
  38. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/test_reresolve.py +51 -3
  39. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/test_validate.py +160 -11
  40. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/test_wheel_selection.py +24 -0
  41. {nab_python-0.0.4 → nab_python-0.0.5}/.gitignore +0 -0
  42. {nab_python-0.0.4 → nab_python-0.0.5}/LICENSE +0 -0
  43. {nab_python-0.0.4 → nab_python-0.0.5}/README.md +0 -0
  44. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/README.md +0 -0
  45. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/_profile_runner.py +0 -0
  46. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/cache/.gitignore +0 -0
  47. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/canary_results/.gitignore +0 -0
  48. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/compare.py +0 -0
  49. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/results/.gitignore +0 -0
  50. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/ai-stack-lowest-direct.toml +0 -0
  51. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/ai-stack-lowest.toml +0 -0
  52. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/ai-stack.toml +0 -0
  53. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/airflow-lowest-direct.toml +0 -0
  54. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/airflow-lowest.toml +0 -0
  55. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/airflow.toml +0 -0
  56. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/big-packages-lowest-direct.toml +0 -0
  57. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/big-packages-lowest.toml +0 -0
  58. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/big-packages.toml +0 -0
  59. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/cross-tracker-lowest-direct.toml +0 -0
  60. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/cross-tracker-lowest.toml +0 -0
  61. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/cross-tracker.toml +0 -0
  62. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/ecosystem-lowest-direct.toml +0 -0
  63. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/ecosystem-lowest.toml +0 -0
  64. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/ecosystem.toml +0 -0
  65. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/forums-lowest-direct.toml +0 -0
  66. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/forums-lowest.toml +0 -0
  67. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/forums.toml +0 -0
  68. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pdm-lowest-direct.toml +0 -0
  69. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pdm-lowest.toml +0 -0
  70. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pdm.toml +0 -0
  71. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pip-lowest-direct.toml +0 -0
  72. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pip-lowest.toml +0 -0
  73. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pip.toml +0 -0
  74. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/poetry-lowest-direct.toml +0 -0
  75. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/poetry-lowest.toml +0 -0
  76. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/poetry.toml +0 -0
  77. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pytorch-lowest-direct.toml +0 -0
  78. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pytorch-lowest.toml +0 -0
  79. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/pytorch.toml +0 -0
  80. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/quick-lowest-direct.toml +0 -0
  81. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/quick-lowest.toml +0 -0
  82. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/quick.toml +0 -0
  83. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/rip-lowest-direct.toml +0 -0
  84. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/rip-lowest.toml +0 -0
  85. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/rip.toml +0 -0
  86. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/universal.toml +0 -0
  87. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/unsupported-lowest-direct.toml +0 -0
  88. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/unsupported-lowest.toml +0 -0
  89. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/unsupported.toml +0 -0
  90. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/uv-lowest-direct.toml +0 -0
  91. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/uv-lowest.toml +0 -0
  92. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/scenarios/uv.toml +0 -0
  93. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/strategy_sweep.py +0 -0
  94. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/strategy_sweep_results/summary.json +0 -0
  95. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/strategy_sweep_summary.py +0 -0
  96. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/universal_scenarios.py +0 -0
  97. {nab_python-0.0.4 → nab_python-0.0.5}/benchmarks/universal_summary.py +0 -0
  98. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/__init__.py +0 -0
  99. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_build/__init__.py +0 -0
  100. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_build/env.py +0 -0
  101. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_build/errors.py +0 -0
  102. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_build/runner.py +0 -0
  103. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_conflict_kind.py +0 -0
  104. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_lockfile/__init__.py +0 -0
  105. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_lockfile/builder.py +0 -0
  106. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_lockfile/disjointness.py +0 -0
  107. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_packaging_provider.py +0 -0
  108. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/__init__.py +0 -0
  109. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/build_remote.py +0 -0
  110. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/lookahead.py +0 -0
  111. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_provider/sources.py +0 -0
  112. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_testing/__init__.py +0 -0
  113. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_testing/coordinator_fake.py +0 -0
  114. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_toml.py +0 -0
  115. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/__init__.py +0 -0
  116. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/LICENSE +0 -0
  117. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/LICENSE.APACHE +0 -0
  118. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/LICENSE.BSD +0 -0
  119. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/PROVENANCE.md +0 -0
  120. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/__init__.py +0 -0
  121. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_elffile.py +0 -0
  122. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_manylinux.py +0 -0
  123. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_musllinux.py +0 -0
  124. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_parser.py +0 -0
  125. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_range_utils.py +0 -0
  126. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_structures.py +0 -0
  127. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_tokenizer.py +0 -0
  128. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/_version_utils.py +0 -0
  129. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/dependency_groups.py +0 -0
  130. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/direct_url.py +0 -0
  131. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/errors.py +0 -0
  132. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/licenses/__init__.py +0 -0
  133. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/licenses/_spdx.py +0 -0
  134. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/markers.py +0 -0
  135. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/metadata.py +0 -0
  136. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/py.typed +0 -0
  137. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/pylock.py +0 -0
  138. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/ranges.py +0 -0
  139. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/requirements.py +0 -0
  140. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/specifiers.py +0 -0
  141. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/tags.py +0 -0
  142. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/utils.py +0 -0
  143. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/_vendor/packaging/version.py +0 -0
  144. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/build_backend.py +0 -0
  145. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/config.py +0 -0
  146. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/py.typed +0 -0
  147. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/requirements_file.py +0 -0
  148. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/universal/__init__.py +0 -0
  149. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/universal/provider.py +0 -0
  150. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/universal/resolve.py +0 -0
  151. {nab_python-0.0.4 → nab_python-0.0.5}/src/nab_python/workspace.py +0 -0
  152. {nab_python-0.0.4 → nab_python-0.0.5}/tests/__init__.py +0 -0
  153. {nab_python-0.0.4 → nab_python-0.0.5}/tests/property_python/__init__.py +0 -0
  154. {nab_python-0.0.4 → nab_python-0.0.5}/tests/property_python/strategies.py +0 -0
  155. {nab_python-0.0.4 → nab_python-0.0.5}/tests/property_python/test_extras_pep685.py +0 -0
  156. {nab_python-0.0.4 → nab_python-0.0.5}/tests/property_python/test_lockfile_pep751.py +0 -0
  157. {nab_python-0.0.4 → nab_python-0.0.5}/tests/property_python/test_marker_overlay.py +0 -0
  158. {nab_python-0.0.4 → nab_python-0.0.5}/tests/property_python/test_multi_index_pep503.py +0 -0
  159. {nab_python-0.0.4 → nab_python-0.0.5}/tests/property_python/test_resolver_pep440.py +0 -0
  160. {nab_python-0.0.4 → nab_python-0.0.5}/tests/ruff.toml +0 -0
  161. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_async_transports.py +0 -0
  162. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_build_backend.py +0 -0
  163. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_build_runner.py +0 -0
  164. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_cache.py +0 -0
  165. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_multi_index.py +0 -0
  166. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_requirements_file.py +0 -0
  167. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_simple_client_filenames.py +0 -0
  168. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_simple_client_hashes.py +0 -0
  169. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_vcs.py +0 -0
  170. {nab_python-0.0.4 → nab_python-0.0.5}/tests/test_workspace.py +0 -0
  171. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/__init__.py +0 -0
  172. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/property_universal/__init__.py +0 -0
  173. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/property_universal/strategies.py +0 -0
  174. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/property_universal/test_alignment.py +0 -0
  175. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/property_universal/test_matrix.py +0 -0
  176. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/property_universal/test_provider_pep425.py +0 -0
  177. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/property_universal/test_validate.py +0 -0
  178. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/test_resolve.py +0 -0
  179. {nab_python-0.0.4 → nab_python-0.0.5}/tests/universal/test_universal_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nab-python
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: Index-backed provider, lockfile emitter, and downloader for nab
5
5
  Project-URL: Homepage, https://github.com/notatallshaw/nab
6
6
  Project-URL: Documentation, https://nab.readthedocs.io/
@@ -20,8 +20,8 @@ Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: build>=1.2
22
22
  Requires-Dist: installer>=0.7
23
- Requires-Dist: nab-index==0.0.4
24
- Requires-Dist: nab-resolver==0.0.4
23
+ Requires-Dist: nab-index==0.0.5
24
+ Requires-Dist: nab-resolver==0.0.5
25
25
  Requires-Dist: pyproject-hooks>=1.2
26
26
  Requires-Dist: tomli-w>=1.2
27
27
  Requires-Dist: tomli>=2.0
@@ -368,8 +368,10 @@ def median_run(scenario: dict, runs: int) -> tuple[list[dict], dict]:
368
368
  else None
369
369
  )
370
370
  uploaded_prior_to = parse_datetime(datetime_str) if datetime_str else None
371
+ # See scenarios.py: trust pre-2.2 sdist PKG-INFO deps by default so the
372
+ # benchmark measures search, not strict PEP 643 sdist rejection.
371
373
  trust_unverified_sdist_deps = bool(
372
- scenario.get("trust_unverified_sdist_deps", False)
374
+ scenario.get("trust_unverified_sdist_deps", True)
373
375
  )
374
376
 
375
377
  runs_data: list[dict] = [
@@ -543,8 +543,13 @@ def process_scenario(
543
543
  f" got {resolution_raw!r}"
544
544
  )
545
545
  raise ValueError(msg) from exc
546
+ # Trust a pre-2.2 sdist's PKG-INFO deps by default. The benchmark measures
547
+ # resolver search, and under BuildPolicy.NEVER the strict PEP 643 product
548
+ # default rejects every sdist-only pre-2.2 pin (UnsupportedSdistError),
549
+ # dropping in-window versions uv resolves by building. A scenario sets this
550
+ # false to exercise strict behavior.
546
551
  trust_unverified_sdist_deps: bool = scenario.get(
547
- "trust_unverified_sdist_deps", False
552
+ "trust_unverified_sdist_deps", True
548
553
  )
549
554
  optional_dependencies: dict[str, list[str]] = scenario.get(
550
555
  "optional_dependencies", {}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nab-python"
3
- version = "0.0.4"
3
+ version = "0.0.5"
4
4
  description = "Index-backed provider, lockfile emitter, and downloader for nab"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -19,8 +19,8 @@ classifiers = [
19
19
  "Typing :: Typed",
20
20
  ]
21
21
  dependencies = [
22
- "nab-resolver==0.0.4",
23
- "nab-index==0.0.4",
22
+ "nab-resolver==0.0.5",
23
+ "nab-index==0.0.5",
24
24
  "tomli>=2.0",
25
25
  "tomli_w>=1.2",
26
26
  "build>=1.2",
@@ -45,11 +45,26 @@ if TYPE_CHECKING:
45
45
 
46
46
 
47
47
  __all__ = [
48
+ "DivergentBaseDependencyError",
48
49
  "build_pylock",
49
50
  "write_lock",
50
51
  ]
51
52
 
52
53
 
54
+ class DivergentBaseDependencyError(ValueError):
55
+ """An environment's conflict forks disagree on a base dependency's pin.
56
+
57
+ A base dependency present in every fork of an environment drops its
58
+ membership clause so it installs even when no conflicting member is
59
+ selected, which requires the forks to agree on one (version, source).
60
+ When they diverge, every candidate entry keeps a membership clause,
61
+ so nothing would fire in the no-member install context and the
62
+ dependency would silently not install. Surface the divergence with
63
+ the offending package and per-fork pins so the producer can
64
+ reconcile the forks rather than commit an incomplete lock.
65
+ """
66
+
67
+
53
68
  def write_lock(
54
69
  lock_input: LockInput,
55
70
  *,
@@ -317,7 +332,9 @@ def _build_per_tuple_packages(lock_input: LockInput, lock_dir: Path) -> list[Pac
317
332
  not by the base is absent from that set, so it keeps the
318
333
  membership clause and does not install when no member is selected.
319
334
  See :class:`LockInput.env_base_names` for the missing-signature
320
- contract.
335
+ contract. Forks of one environment that disagree on a base
336
+ dependency's pin raise :class:`DivergentBaseDependencyError`
337
+ instead of emitting a lock whose no-member context misses it.
321
338
  """
322
339
  out: list[Package] = []
323
340
  by_name = _group_by_name(lock_input.per_tuple_pins)
@@ -330,6 +347,14 @@ def _build_per_tuple_packages(lock_input: LockInput, lock_dir: Path) -> list[Pac
330
347
  )
331
348
  for canonical_name, per_tuple in by_name.items():
332
349
  groups = _group_pins_by_pin(per_tuple)
350
+ _check_base_fork_agreement(
351
+ canonical_name,
352
+ per_tuple,
353
+ groups,
354
+ env_signatures,
355
+ env_fork_counts,
356
+ lock_input.env_base_names,
357
+ )
333
358
  for pins, tuple_labels in groups:
334
359
  marker = _build_marker(
335
360
  canonical_name,
@@ -452,6 +477,46 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
452
477
  )
453
478
 
454
479
 
480
+ def _check_base_fork_agreement(
481
+ name: str,
482
+ per_tuple: Mapping[str, PinShape],
483
+ groups: list[tuple[list[PinShape], list[str]]],
484
+ env_signatures: Mapping[str, tuple[tuple[str, str], ...]],
485
+ env_fork_counts: Mapping[tuple[tuple[str, str], ...], int],
486
+ env_base_names: Mapping[tuple[tuple[str, str], ...], frozenset[str]],
487
+ ) -> None:
488
+ """Reject a base dep whose forks within one env pin it differently.
489
+
490
+ The env-only collapse in :func:`_build_marker` needs a single
491
+ (version, source) group spanning every fork of the environment.
492
+ Divergent pins split the forks across groups, so every entry would
493
+ keep its membership clause and none would fire when no member is
494
+ selected: the base dependency would silently not install.
495
+ """
496
+ by_env: defaultdict[tuple[tuple[str, str], ...], list[str]] = defaultdict(list)
497
+ for label in sorted(per_tuple.keys() & env_signatures.keys()):
498
+ by_env[env_signatures[label]].append(label)
499
+ for signature, labels in by_env.items():
500
+ if len(labels) < env_fork_counts[signature]:
501
+ continue
502
+ if name not in env_base_names.get(signature, frozenset()):
503
+ continue
504
+ in_env = set(labels)
505
+ widest = max(
506
+ sum(1 for label in group_labels if label in in_env)
507
+ for _, group_labels in groups
508
+ )
509
+ if widest >= env_fork_counts[signature]:
510
+ continue
511
+ forks = ", ".join(f"{label} -> {per_tuple[label].version}" for label in labels)
512
+ msg = (
513
+ f"{name}: the conflict forks of one environment pin this base"
514
+ f" dependency differently ({forks}); no lockfile entry would"
515
+ " install it when no conflicting member is selected"
516
+ )
517
+ raise DivergentBaseDependencyError(msg)
518
+
519
+
455
520
  def _build_marker(
456
521
  name: str,
457
522
  tuple_labels: Sequence[str],
@@ -33,8 +33,10 @@ def write_requirements_with_hashes(
33
33
  Each line is ``name==version`` followed by one ``--hash=sha256:...``
34
34
  per recorded artefact, in the format pip's hash-checking mode
35
35
  accepts. Local and VCS pins are emitted as ``name @ <url>`` lines
36
- without hashes (pip does not hash-check those forms). Returns the
37
- text and, when ``output_path`` is provided, atomically writes it.
36
+ without hashes (pip does not hash-check those forms); an editable
37
+ local pin renders as ``-e <url>`` and a ``subdirectory`` as a
38
+ ``#subdirectory=`` fragment. Returns the text and, when
39
+ ``output_path`` is provided, atomically writes it.
38
40
  """
39
41
  return _render_requirements(lock_input, with_hashes=True, output_path=output_path)
40
42
 
@@ -45,8 +47,8 @@ def write_requirements_without_hashes(
45
47
  """Render ``lock_input`` as a plain ``name==version`` list.
46
48
 
47
49
  Same shape as :func:`write_requirements_with_hashes` but without
48
- the ``--hash=sha256:...`` lines. Local and VCS pins still render
49
- as ``name @ <url>``. Returns the text and, when ``output_path``
50
+ the ``--hash=sha256:...`` lines. Local and VCS pins render the
51
+ same in both variants. Returns the text and, when ``output_path``
50
52
  is provided, atomically writes it.
51
53
  """
52
54
  return _render_requirements(lock_input, with_hashes=False, output_path=output_path)
@@ -97,7 +99,13 @@ def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str
97
99
  if isinstance(pin, IndexPin):
98
100
  lines.extend(_render_index_pin(pin, with_hashes=with_hashes))
99
101
  elif isinstance(pin, LocalPin):
100
- lines.append(f"{pin.name} @ {Path(pin.path).resolve().as_uri()}")
102
+ url = Path(pin.path).resolve().as_uri()
103
+ if pin.subdirectory is not None:
104
+ url += f"#subdirectory={pin.subdirectory}"
105
+ if pin.editable:
106
+ lines.append(f"-e {url}")
107
+ else:
108
+ lines.append(f"{pin.name} @ {url}")
101
109
  elif isinstance(pin, VcsPin):
102
110
  lines.append(f"{pin.name} @ {pin.repo_url}")
103
111
  else: # pragma: no cover - exhaustive
@@ -76,17 +76,23 @@ def _pick_in_mode(
76
76
  ) -> Version | None:
77
77
  """Pick a candidate honoring ``ExtrasMode``.
78
78
 
79
- User-requested extras return the first candidate that does not have
80
- known-invalid metadata. Transitive extras additionally validate the
81
- base metadata parses, so a malformed PKG-INFO becomes a candidate
82
- skip instead of a fatal error during the later dependency fetch.
83
- BACKTRACK mode additionally checks ``Provides-Extra``.
84
-
85
- Missing-metadata cases (no PEP 658, no sdist) fall through;
86
- mock test coordinators rely on this.
79
+ Fetches base metadata so an extraction failure (unparseable
80
+ PKG-INFO, or an sdist build the policy disallows) becomes a
81
+ candidate skip instead of a fatal error during the later
82
+ dependency fetch. BACKTRACK mode additionally checks
83
+ ``Provides-Extra`` for transitive extras.
84
+
85
+ Missing-metadata cases (no PEP 658, no sdist) skip transitive
86
+ extras but fall through for user-requested ones; mock test
87
+ coordinators rely on this.
87
88
  """
88
89
  # Late import: ``pypi`` imports this module at module load.
89
- from ..provider import ExtrasMode, MetadataError, _normalize_extra
90
+ from ..provider import (
91
+ ExtrasMode,
92
+ MetadataError,
93
+ UnsupportedSdistError,
94
+ _normalize_extra,
95
+ )
90
96
 
91
97
  _, _, normalized = provider.split_and_normalize(base)
92
98
  is_user = (normalized, extra) in provider.root_extras
@@ -94,17 +100,15 @@ def _pick_in_mode(
94
100
  for version in candidates:
95
101
  if provider.has_invalid_metadata(normalized, version):
96
102
  continue
97
- if is_user:
98
- return version
99
- # Fetch base metadata so an unparseable PKG-INFO is caught
100
- # before the extras proxy decides this version. Any
101
- # MetadataError (parse failure or no metadata source) is a
102
- # candidate skip.
103
103
  try:
104
104
  provider.get_dependencies(base, version)
105
- except MetadataError:
105
+ except UnsupportedSdistError:
106
106
  continue
107
- if not backtrack:
107
+ except MetadataError:
108
+ if not is_user or provider.has_invalid_metadata(normalized, version):
109
+ continue
110
+ return version
111
+ if is_user or not backtrack:
108
112
  return version
109
113
  metadata = provider.metadata_cache.get((normalized, version))
110
114
  provided = (
@@ -188,7 +192,11 @@ def get_extra_dependencies(
188
192
  return handle_missing_extra(provider, normalized, extra, version, cache_key)
189
193
 
190
194
  deps = dict(extra_map[extra])
191
- deps[normalized] = VersionRange.singleton(version)
195
+ # Pin the base, intersected with any bound the extra itself
196
+ # places on it (``foo>=2; extra == "bar"``).
197
+ deps[normalized] = deps.get(
198
+ normalized, VersionRange.full()
199
+ ) & VersionRange.singleton(version)
192
200
 
193
201
  provider.deps_cache[cache_key] = deps
194
202
  provider.prefetch_new_deps(deps)
@@ -223,9 +231,10 @@ def handle_missing_extra(
223
231
  version,
224
232
  extra,
225
233
  )
226
- # Return empty deps: the extra doesn't exist, so the proxy
227
- # contributes nothing. Don't pin to the base version, as that
228
- # creates unnecessary coupling that causes backtracking storms
229
- # when the resolver tries many base versions.
230
- provider.deps_cache[cache_key] = {}
231
- return {}
234
+ # The extra contributes no deps at this version, but the proxy
235
+ # must still pin its base: without the pin the proxy and the base
236
+ # can settle on different versions, and if the base's version does
237
+ # provide the extra its dependencies are silently dropped.
238
+ deps = {normalized: VersionRange.singleton(version)}
239
+ provider.deps_cache[cache_key] = deps
240
+ return deps
@@ -271,10 +271,10 @@ def excluded_by_python(provider: Provider, dist: DistFile) -> bool:
271
271
  try:
272
272
  spec = SpecifierSet(requires_python)
273
273
  cached = Version(provider.python_version) not in spec
274
- except (InvalidSpecifier, InvalidVersion):
275
- # Malformed Requires-Python on the dist or our own
276
- # python_version: treat as not-excluded, let downstream
277
- # logic decide.
274
+ except InvalidSpecifier:
275
+ # Malformed Requires-Python on the dist: treat as
276
+ # not-excluded, let downstream logic decide. Our own
277
+ # python_version is validated at Provider construction.
278
278
  cached = False
279
279
  provider.requires_python_cache[requires_python] = cached
280
280
  if cached:
@@ -425,14 +425,21 @@ def await_metadata_batch(
425
425
  event.wait()
426
426
  text = provider.coordinator.index.get_metadata(package, ver_str)
427
427
  if text is None:
428
- provider.deps_cache[cache_key] = {}
429
- else:
430
- try:
431
- provider.parse_and_cache_metadata(cache_key, text)
432
- except (ValueError, InvalidVersion, InvalidSpecifier):
433
- # Malformed metadata: cache empty deps so the candidate
434
- # acts as if it had no deps rather than bubbling.
435
- provider.deps_cache[cache_key] = {}
428
+ # No PEP 658 text arrived: leave the version un-cached so
429
+ # look-ahead's get_dependencies runs the sdist fallback (or
430
+ # refuses it) rather than pinning it as dependency-free.
431
+ continue
432
+ if provider.coordinator.index.metadata_from_sdist(package, ver_str):
433
+ # The shared slot holds sdist PKG-INFO from an earlier
434
+ # fallback; caching it here would skip the PEP 643 gate
435
+ # that get_dependencies applies on the from_sdist path.
436
+ continue
437
+ try:
438
+ provider.parse_and_cache_metadata(cache_key, text)
439
+ except (ValueError, InvalidVersion, InvalidSpecifier):
440
+ # Malformed metadata: same reason, refuse via get_dependencies
441
+ # (_invalid_metadata) instead of caching empty deps.
442
+ continue
436
443
 
437
444
 
438
445
  def prefetch_new_deps(provider: Provider, deps: Mapping[str, VersionRange]) -> None:
@@ -48,11 +48,11 @@ def resolve_metadata(
48
48
 
49
49
  text = provider.coordinator.index.get_metadata(normalized, ver_str)
50
50
  if text is not None:
51
- # Decide source from listing: a wheel-with-metadata-url at this
52
- # version means the text was wheel METADATA; otherwise sdist
53
- # PKG-INFO. ``_metadata`` and ``_sdist`` write to the same
54
- # slot, so we can't tell from the index alone.
55
- from_sdist = not has_wheel_metadata_at(versions, version)
51
+ # Wheel METADATA and sdist PKG-INFO share the slot; the index
52
+ # records which kind the last write was. Inferring from the
53
+ # listing instead would mislabel the text whenever this
54
+ # provider's view differs from the one that stored it.
55
+ from_sdist = provider.coordinator.index.metadata_from_sdist(normalized, ver_str)
56
56
  return (text, from_sdist)
57
57
 
58
58
  dist = pick_dist_for_metadata(versions, version)
@@ -85,18 +85,6 @@ def resolve_metadata(
85
85
  raise MetadataError(msg)
86
86
 
87
87
 
88
- def has_wheel_metadata_at(
89
- versions: Sequence[tuple[Version, DistFile]], version: Version
90
- ) -> bool:
91
- """Report whether the listing has a wheel with PEP 658 metadata."""
92
- for v, d in versions:
93
- if v != version:
94
- continue
95
- if isinstance(d, WheelFile) and d.metadata_url is not None:
96
- return True
97
- return False
98
-
99
-
100
88
  def pick_dist_for_metadata(
101
89
  versions: Sequence[tuple[Version, DistFile]], version: Version
102
90
  ) -> DistFile | None:
@@ -93,7 +93,9 @@ def compute_matching(
93
93
  elif has_local_source:
94
94
  matching = 1
95
95
  else:
96
- matching = _NO_LISTING_PRIOR
96
+ # Not cached, so the next call re-checks the index and the
97
+ # listing-arrival side effect above can still fire.
98
+ return _NO_LISTING_PRIOR
97
99
 
98
100
  if per_pkg is None:
99
101
  per_pkg = provider.matching_cache[normalized] = {}
@@ -103,15 +103,16 @@ def split_vcs_scheme(url: str) -> tuple[str | None, str]:
103
103
  def has_full_commit_sha(url: str) -> bool:
104
104
  """Return True if the URL pins to a 40-char hex commit hash.
105
105
 
106
- Looks for ``@<sha>`` after the scheme://host portion; ignores any
107
- ``#`` fragment. Tolerates ``user@host`` syntax by taking the last
108
- ``@`` in the path/ref portion.
106
+ Looks for ``@<sha>`` in the path component (after the authority);
107
+ ignores any ``#`` fragment. A ``user@host`` in the authority is
108
+ left alone, matching the ref parsing in :mod:`nab_index.vcs`.
109
109
  """
110
110
  fragmentless = url.split("#", 1)[0]
111
- after_authority = fragmentless.split("://", 1)[-1]
112
- if "@" not in after_authority:
111
+ after_scheme = fragmentless.split("://", 1)[-1]
112
+ path = after_scheme.partition("/")[2]
113
+ if "@" not in path:
113
114
  return False
114
- ref = after_authority.rsplit("@", 1)[1]
115
+ ref = path.rsplit("@", 1)[1]
115
116
  return bool(FULL_GIT_SHA_RE.match(ref))
116
117
 
117
118
 
@@ -104,6 +104,8 @@ def _entries_for_pin(canonical: str, pin: PinShape) -> Iterable[DownloadEntry]:
104
104
 
105
105
 
106
106
  def _iter_index_pin(canonical: str, pin: IndexPin) -> Iterable[DownloadEntry]:
107
+ # Recorded digests are lowercased to match hashlib.hexdigest() output:
108
+ # index-fed flows already lowercase, but a caller-built LockInput may not.
107
109
  if pin.sdist is not None:
108
110
  algo, digest = pin.sdist.primary_digest
109
111
  yield DownloadEntry(
@@ -112,7 +114,7 @@ def _iter_index_pin(canonical: str, pin: IndexPin) -> Iterable[DownloadEntry]:
112
114
  filename=pin.sdist.filename,
113
115
  url=pin.sdist.url,
114
116
  hash_algo=algo,
115
- digest=digest,
117
+ digest=digest.lower(),
116
118
  )
117
119
  for wheel in pin.wheels:
118
120
  algo, digest = wheel.primary_digest
@@ -122,7 +124,7 @@ def _iter_index_pin(canonical: str, pin: IndexPin) -> Iterable[DownloadEntry]:
122
124
  filename=wheel.filename,
123
125
  url=wheel.url,
124
126
  hash_algo=algo,
125
- digest=digest,
127
+ digest=digest.lower(),
126
128
  )
127
129
 
128
130
 
@@ -141,6 +141,10 @@ class InMemoryIndex:
141
141
  self._listing_errors: dict[str, BaseException] = {}
142
142
  self._listing_indexes: dict[str, str] = {}
143
143
  self._metadata: dict[tuple[str, str], str | None] = {}
144
+ # Keys whose ``_metadata`` slot was last written from an sdist
145
+ # PKG-INFO rather than a wheel METADATA; readers need the
146
+ # origin because only sdist deps go through the PEP 643 gate.
147
+ self._metadata_from_sdist: set[tuple[str, str]] = set()
144
148
  self._sdist_pyproject: dict[tuple[str, str], str | None] = {}
145
149
  self._sdist_archives: dict[tuple[str, str], bytes | None] = {}
146
150
  self._pending: dict[str, _Pending] = {}
@@ -227,6 +231,7 @@ class InMemoryIndex:
227
231
  key = f"metadata:{package}:{version}"
228
232
  with self._lock:
229
233
  self._metadata[(package, version)] = data
234
+ self._metadata_from_sdist.discard((package, version))
230
235
  pending = self._pending.get(key)
231
236
  if pending is not None:
232
237
  pending.result = data
@@ -241,15 +246,27 @@ class InMemoryIndex:
241
246
  because PKG-INFO is core-metadata-equivalent. The pending
242
247
  keys differ so a sdist request can run in parallel with (or
243
248
  after) a failed wheel metadata request.
249
+ :meth:`metadata_from_sdist` reports which kind the slot holds.
244
250
  """
245
251
  key = f"sdist:{package}:{version}"
246
252
  with self._lock:
247
253
  self._metadata[(package, version)] = data
254
+ self._metadata_from_sdist.add((package, version))
248
255
  pending = self._pending.get(key)
249
256
  if pending is not None:
250
257
  pending.result = data
251
258
  pending.event.set()
252
259
 
260
+ def metadata_from_sdist(self, package: str, version: str) -> bool:
261
+ """Return ``True`` when the metadata slot was last written from an sdist.
262
+
263
+ The slot itself cannot distinguish wheel METADATA from sdist
264
+ PKG-INFO; readers that apply the :pep:`643` dynamic-deps gate
265
+ only to sdist values ask here for the current text's origin.
266
+ """
267
+ with self._lock:
268
+ return (package, version) in self._metadata_from_sdist
269
+
253
270
  def store_sdist_pyproject(self, package: str, version: str, data: str) -> None:
254
271
  """Store sdist-derived pyproject.toml text for static-metadata fallback.
255
272
 
@@ -470,6 +487,11 @@ class FetchCoordinator:
470
487
  self._thread.join(timeout=_COORDINATOR_JOIN_TIMEOUT_SECONDS)
471
488
  self._thread = None
472
489
  self._started = False
490
+ # Drop the dead loop so a later start() waits for the fresh one
491
+ # instead of submitting to a closed loop.
492
+ self._loop = None
493
+ self._async_q = None
494
+ self._queue_ready.clear()
473
495
 
474
496
  def _submit(self, item: _QueueItem) -> None:
475
497
  """Schedule ``item`` on the fetcher loop's queue from any thread."""
@@ -711,7 +733,8 @@ class FetchCoordinator:
711
733
 
712
734
  client = self._build_client()
713
735
  try:
714
- while True:
736
+ stopping = False
737
+ while not stopping:
715
738
  item = await queue.get()
716
739
  if item is None:
717
740
  break
@@ -725,10 +748,11 @@ class FetchCoordinator:
725
748
  except asyncio.QueueEmpty:
726
749
  break
727
750
  if extra is None:
728
- for t in tasks:
729
- t.cancel()
730
- await asyncio.gather(*tasks, return_exceptions=True)
731
- return
751
+ # Fall through to the gather below instead of
752
+ # cancelling: a cancelled _handle never records a
753
+ # result, leaving its waiter's event unset forever.
754
+ stopping = True
755
+ break
732
756
  self._dispatch(extra, client, sem, tasks)
733
757
 
734
758
  if tasks:
@@ -856,9 +880,12 @@ class FetchCoordinator:
856
880
  pkg_info, pyproject = await client.get_sdist_files(
857
881
  req.package, req.version, req.url
858
882
  )
859
- self.index.store_sdist_metadata(req.package, req.version, pkg_info)
883
+ # Store pyproject.toml first: store_sdist_metadata fires the
884
+ # pending event, and a released waiter reads the pyproject slot
885
+ # with no further synchronisation.
860
886
  if pyproject is not None:
861
887
  self.index.store_sdist_pyproject(req.package, req.version, pyproject)
888
+ self.index.store_sdist_metadata(req.package, req.version, pkg_info)
862
889
 
863
890
  async def _fetch_sdist_archive(
864
891
  self,
@@ -24,7 +24,7 @@ from ._lockfile.builder import (
24
24
  read_lockfile_packages,
25
25
  )
26
26
  from ._lockfile.disjointness import DisjointnessError
27
- from ._lockfile.pylock import build_pylock, write_lock
27
+ from ._lockfile.pylock import DivergentBaseDependencyError, build_pylock, write_lock
28
28
  from ._lockfile.requirements import (
29
29
  write_requirements_with_hashes,
30
30
  write_requirements_without_hashes,
@@ -44,6 +44,7 @@ __all__ = [
44
44
  "ACCEPTED_HASH_ALGORITHMS",
45
45
  "LOCK_VERSION",
46
46
  "DisjointnessError",
47
+ "DivergentBaseDependencyError",
47
48
  "IndexPin",
48
49
  "LocalPin",
49
50
  "LockInput",
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  import email.parser
13
13
  from dataclasses import dataclass, field
14
14
  from functools import lru_cache
15
- from typing import Any
15
+ from typing import TYPE_CHECKING, Any
16
16
 
17
17
  import tomli
18
18
 
@@ -20,6 +20,9 @@ from ._vendor.packaging.requirements import Requirement
20
20
  from ._vendor.packaging.specifiers import SpecifierSet
21
21
  from ._vendor.packaging.version import Version
22
22
 
23
+ if TYPE_CHECKING:
24
+ from ._vendor.packaging.markers import Marker
25
+
23
26
  __all__ = [
24
27
  "DEPENDENCY_FIELDS",
25
28
  "WheelMetadata",
@@ -88,6 +91,22 @@ def metadata_deps_are_static(metadata: WheelMetadata) -> bool:
88
91
  return not (DEPENDENCY_FIELDS & metadata.dynamic)
89
92
 
90
93
 
94
+ @lru_cache(maxsize=8192)
95
+ def _intern_marker(marker: Marker) -> Marker:
96
+ """Return a shared :class:`Marker` for an equal marker expression.
97
+
98
+ ``Marker`` hashes and compares by its text, so a single marker like
99
+ ``extra == "test"`` recurs across hundreds of distinct dep strings
100
+ (``pytest; extra == "test"``, ``coverage; extra == "test"``, ...),
101
+ each parsing to its own object. The provider caches marker
102
+ evaluation by ``id(marker)``, so sharing one object per distinct
103
+ expression lets that cache hit across every candidate instead of
104
+ re-evaluating the same expression per dep. Markers are read-only,
105
+ so sharing is safe.
106
+ """
107
+ return marker
108
+
109
+
91
110
  @lru_cache(maxsize=16384)
92
111
  def _parse_requirement_cached(req_str: str) -> Requirement:
93
112
  """Cache ``Requirement(req_str)`` parsing across wheel metadata.
@@ -97,7 +116,10 @@ def _parse_requirement_cached(req_str: str) -> Requirement:
97
116
  only read operations (specifier, marker, extras, name) so sharing
98
117
  parsed objects is safe.
99
118
  """
100
- return Requirement(req_str)
119
+ req = Requirement(req_str)
120
+ if req.marker is not None:
121
+ req.marker = _intern_marker(req.marker)
122
+ return req
101
123
 
102
124
 
103
125
  @lru_cache(maxsize=65536)
@@ -7,7 +7,6 @@ types. Uses a thread pool with a shared HTTP session to overlap I/O.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import contextlib
11
10
  import enum
12
11
  import logging
13
12
  import re
@@ -98,16 +97,17 @@ _PYTHON_FULL_VERSION_PARTS = 3
98
97
  def python_axis_environment(python_version: str) -> dict[str, str]:
99
98
  """Map an explicit Python version to its PEP 508 marker keys.
100
99
 
101
- ``python_full_version`` is padded to three components so patch-precision
102
- markers evaluate the same here as in the universal matrix. Raises
103
- ``InvalidVersion`` if the input is not a version.
100
+ ``python_version`` is padded to two components and
101
+ ``python_full_version`` to three so patch-precision markers evaluate
102
+ the same here as in the universal matrix. Raises ``InvalidVersion``
103
+ if the input is not a version.
104
104
  """
105
- release = Version(python_version).release
106
- minor = (
107
- f"{release[0]}.{release[1]}"
108
- if len(release) >= _PYTHON_VERSION_PARTS
109
- else python_version
110
- )
105
+ try:
106
+ release = Version(python_version).release
107
+ except InvalidVersion:
108
+ msg = f"python_version {python_version!r} is not a valid version"
109
+ raise InvalidVersion(msg) from None
110
+ minor = ".".join(str(part) for part in (*release, 0)[:_PYTHON_VERSION_PARTS])
111
111
  full = (
112
112
  python_version
113
113
  if len(release) >= _PYTHON_FULL_VERSION_PARTS
@@ -494,8 +494,7 @@ class Provider:
494
494
  }
495
495
  self.environment: dict[str, str] = env_init
496
496
  if python_version is not None:
497
- with contextlib.suppress(InvalidVersion):
498
- self.environment.update(python_axis_environment(python_version))
497
+ self.environment.update(python_axis_environment(python_version))
499
498
  if marker_environment:
500
499
  for key, value in marker_environment.items():
501
500
  self.environment[key] = value