nab-python 0.0.6__tar.gz → 0.0.8__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 (205) hide show
  1. {nab_python-0.0.6 → nab_python-0.0.8}/PKG-INFO +3 -3
  2. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/canary.py +7 -2
  3. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios.py +7 -2
  4. {nab_python-0.0.6 → nab_python-0.0.8}/pyproject.toml +3 -3
  5. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_build/runner.py +14 -10
  6. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_lockfile/builder.py +62 -4
  7. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_lockfile/pylock.py +29 -6
  8. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_lockfile/requirements.py +16 -5
  9. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_packaging_provider.py +24 -2
  10. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_provider/extras.py +64 -33
  11. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_provider/listing.py +16 -4
  12. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_provider/metadata_resolver.py +59 -38
  13. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_provider/priority.py +4 -2
  14. nab_python-0.0.8/src/nab_python/_provider/sources.py +396 -0
  15. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vcs_admission.py +47 -1
  16. nab_python-0.0.8/src/nab_python/_vendor/packaging/PROVENANCE.md +61 -0
  17. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/_manylinux.py +0 -2
  18. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/_musllinux.py +1 -1
  19. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/_ranges.py +17 -17
  20. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/dependency_groups.py +5 -4
  21. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/direct_url.py +17 -8
  22. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/markers.py +46 -9
  23. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/metadata.py +8 -2
  24. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/pylock.py +4 -1
  25. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/ranges.py +251 -57
  26. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/requirements.py +13 -2
  27. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/specifiers.py +6 -6
  28. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/tags.py +14 -5
  29. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/version.py +21 -5
  30. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/build_backend.py +82 -38
  31. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/config.py +99 -5
  32. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/config_sources.py +34 -2
  33. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/download.py +48 -9
  34. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/fetch.py +50 -7
  35. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/lockfile.py +32 -1
  36. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/metadata.py +14 -2
  37. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/provider.py +231 -48
  38. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/requirements_file.py +273 -24
  39. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/resolve.py +47 -16
  40. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/universal/provider.py +25 -8
  41. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/universal/reresolve.py +13 -0
  42. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/universal/resolve.py +33 -7
  43. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/universal/validate.py +19 -8
  44. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/workspace.py +18 -5
  45. nab_python-0.0.8/tests/property_python/test_cached_client_equiv.py +320 -0
  46. nab_python-0.0.8/tests/property_python/test_conflict_fork_markers.py +249 -0
  47. nab_python-0.0.8/tests/property_python/test_disjointness_differential.py +293 -0
  48. nab_python-0.0.8/tests/property_python/test_filename_differential.py +183 -0
  49. nab_python-0.0.8/tests/property_python/test_from_bounds_snap_bounds.py +119 -0
  50. nab_python-0.0.8/tests/property_python/test_multi_index_contract.py +180 -0
  51. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_prerelease_admission.py +3 -0
  52. nab_python-0.0.8/tests/property_python/test_sdist_archive_safety.py +210 -0
  53. nab_python-0.0.8/tests/property_python/test_universal_faithfulness.py +259 -0
  54. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_vcs_admission.py +30 -2
  55. nab_python-0.0.8/tests/test_archive.py +472 -0
  56. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_async_transports.py +79 -0
  57. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_benchmark_parse_requirements.py +3 -1
  58. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_build_backend.py +88 -50
  59. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_build_runner.py +4 -6
  60. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_cache.py +30 -5
  61. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_cached_client.py +254 -10
  62. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_config.py +151 -2
  63. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_config_sources.py +9 -0
  64. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_fetch.py +140 -0
  65. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_local_index.py +202 -0
  66. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_lockfile.py +214 -0
  67. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_marker_evaluation.py +38 -0
  68. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_metadata.py +24 -0
  69. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_multi_index.py +75 -1
  70. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_provider.py +640 -85
  71. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_requirements_file.py +395 -0
  72. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_resolve.py +107 -5
  73. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_resolver_packaging.py +100 -75
  74. nab_python-0.0.8/tests/test_simple_client_malformed.py +92 -0
  75. nab_python-0.0.8/tests/test_simple_client_size.py +46 -0
  76. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_vcs.py +24 -0
  77. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_vcs_admission.py +147 -3
  78. nab_python-0.0.8/tests/test_vendor_from_bounds_snap_bounds.py +214 -0
  79. nab_python-0.0.8/tests/test_widen_decision.py +269 -0
  80. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_workspace.py +28 -0
  81. nab_python-0.0.8/tests/universal/property_universal/test_matrix_labels.py +158 -0
  82. nab_python-0.0.8/tests/universal/property_universal/test_reresolve_fixed_point.py +168 -0
  83. nab_python-0.0.8/tests/universal/property_universal/test_validate_mutations.py +184 -0
  84. nab_python-0.0.8/tests/universal/property_universal/test_wheel_selection_differential.py +316 -0
  85. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/test_resolve.py +92 -8
  86. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/test_universal_provider.py +157 -0
  87. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/test_validate.py +128 -15
  88. nab_python-0.0.6/src/nab_python/_provider/sources.py +0 -223
  89. nab_python-0.0.6/src/nab_python/_vendor/packaging/PROVENANCE.md +0 -92
  90. {nab_python-0.0.6 → nab_python-0.0.8}/.gitignore +0 -0
  91. {nab_python-0.0.6 → nab_python-0.0.8}/LICENSE +0 -0
  92. {nab_python-0.0.6 → nab_python-0.0.8}/README.md +0 -0
  93. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/README.md +0 -0
  94. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/_profile_runner.py +0 -0
  95. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/cache/.gitignore +0 -0
  96. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/canary_results/.gitignore +0 -0
  97. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/compare.py +0 -0
  98. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/results/.gitignore +0 -0
  99. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/ai-stack-lowest-direct.toml +0 -0
  100. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/ai-stack-lowest.toml +0 -0
  101. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/ai-stack.toml +0 -0
  102. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/airflow-lowest-direct.toml +0 -0
  103. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/airflow-lowest.toml +0 -0
  104. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/airflow.toml +0 -0
  105. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/big-packages-lowest-direct.toml +0 -0
  106. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/big-packages-lowest.toml +0 -0
  107. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/big-packages.toml +0 -0
  108. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/cross-tracker-lowest-direct.toml +0 -0
  109. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/cross-tracker-lowest.toml +0 -0
  110. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/cross-tracker.toml +0 -0
  111. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/ecosystem-lowest-direct.toml +0 -0
  112. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/ecosystem-lowest.toml +0 -0
  113. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/ecosystem.toml +0 -0
  114. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/forums-lowest-direct.toml +0 -0
  115. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/forums-lowest.toml +0 -0
  116. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/forums.toml +0 -0
  117. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pdm-lowest-direct.toml +0 -0
  118. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pdm-lowest.toml +0 -0
  119. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pdm.toml +0 -0
  120. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pip-lowest-direct.toml +0 -0
  121. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pip-lowest.toml +0 -0
  122. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pip.toml +0 -0
  123. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/poetry-lowest-direct.toml +0 -0
  124. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/poetry-lowest.toml +0 -0
  125. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/poetry.toml +0 -0
  126. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pytorch-lowest-direct.toml +0 -0
  127. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pytorch-lowest.toml +0 -0
  128. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/pytorch.toml +0 -0
  129. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/quick-lowest-direct.toml +0 -0
  130. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/quick-lowest.toml +0 -0
  131. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/quick.toml +0 -0
  132. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/rip-lowest-direct.toml +0 -0
  133. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/rip-lowest.toml +0 -0
  134. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/rip.toml +0 -0
  135. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/universal.toml +0 -0
  136. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/unsupported-lowest-direct.toml +0 -0
  137. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/unsupported-lowest.toml +0 -0
  138. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/unsupported.toml +0 -0
  139. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/uv-lowest-direct.toml +0 -0
  140. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/uv-lowest.toml +0 -0
  141. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/scenarios/uv.toml +0 -0
  142. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/strategy_sweep.py +0 -0
  143. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/strategy_sweep_results/summary.json +0 -0
  144. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/strategy_sweep_summary.py +0 -0
  145. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/universal_scenarios.py +0 -0
  146. {nab_python-0.0.6 → nab_python-0.0.8}/benchmarks/universal_summary.py +0 -0
  147. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/__init__.py +0 -0
  148. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_build/__init__.py +0 -0
  149. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_build/env.py +0 -0
  150. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_build/errors.py +0 -0
  151. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_conflict_kind.py +0 -0
  152. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_lockfile/__init__.py +0 -0
  153. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_lockfile/disjointness.py +0 -0
  154. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_provider/__init__.py +0 -0
  155. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_provider/build_remote.py +0 -0
  156. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_provider/lookahead.py +0 -0
  157. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_testing/__init__.py +0 -0
  158. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_testing/coordinator_fake.py +0 -0
  159. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_testing/overrides.py +0 -0
  160. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_toml.py +0 -0
  161. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/__init__.py +0 -0
  162. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/LICENSE +0 -0
  163. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/LICENSE.APACHE +0 -0
  164. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/LICENSE.BSD +0 -0
  165. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/__init__.py +0 -0
  166. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/_elffile.py +0 -0
  167. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/_parser.py +0 -0
  168. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/_structures.py +0 -0
  169. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/_tokenizer.py +0 -0
  170. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/errors.py +0 -0
  171. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/licenses/__init__.py +0 -0
  172. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/licenses/_spdx.py +0 -0
  173. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/py.typed +0 -0
  174. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/_vendor/packaging/utils.py +0 -0
  175. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/py.typed +0 -0
  176. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/universal/__init__.py +0 -0
  177. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/universal/matrix.py +0 -0
  178. {nab_python-0.0.6 → nab_python-0.0.8}/src/nab_python/universal/wheel_selection.py +0 -0
  179. {nab_python-0.0.6 → nab_python-0.0.8}/tests/__init__.py +0 -0
  180. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/__init__.py +0 -0
  181. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/strategies.py +0 -0
  182. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_build_policy_never.py +0 -0
  183. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_download_hashes.py +0 -0
  184. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_extras_pep685.py +0 -0
  185. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_fetch_coordinator.py +0 -0
  186. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_lockfile_pep751.py +0 -0
  187. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_marker_overlay.py +0 -0
  188. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_multi_index_pep503.py +0 -0
  189. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_override_overlap.py +0 -0
  190. {nab_python-0.0.6 → nab_python-0.0.8}/tests/property_python/test_resolver_pep440.py +0 -0
  191. {nab_python-0.0.6 → nab_python-0.0.8}/tests/ruff.toml +0 -0
  192. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_download.py +0 -0
  193. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_simple_client_filenames.py +0 -0
  194. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_simple_client_hashes.py +0 -0
  195. {nab_python-0.0.6 → nab_python-0.0.8}/tests/test_simple_client_upload_time.py +0 -0
  196. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/__init__.py +0 -0
  197. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/property_universal/__init__.py +0 -0
  198. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/property_universal/strategies.py +0 -0
  199. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/property_universal/test_alignment.py +0 -0
  200. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/property_universal/test_matrix.py +0 -0
  201. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/property_universal/test_provider_pep425.py +0 -0
  202. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/property_universal/test_validate.py +0 -0
  203. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/test_matrix.py +0 -0
  204. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/test_reresolve.py +0 -0
  205. {nab_python-0.0.6 → nab_python-0.0.8}/tests/universal/test_wheel_selection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nab-python
3
- Version: 0.0.6
3
+ Version: 0.0.8
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.6
24
- Requires-Dist: nab-resolver==0.0.6
23
+ Requires-Dist: nab-index==0.0.8
24
+ Requires-Dist: nab-resolver==0.0.8
25
25
  Requires-Dist: pyproject-hooks>=1.2
26
26
  Requires-Dist: tomli-w>=1.2
27
27
  Requires-Dist: tomli>=2.0
@@ -155,9 +155,14 @@ def parse_requirements(
155
155
  )
156
156
  raise NotImplementedError(msg)
157
157
  name = canonicalize_name(req.name)
158
- reqs[name] = reqs.get(name, VersionRange.full()) & req.specifier.to_range()
158
+ term = (
159
+ req.specifier.to_range()
160
+ if req.specifier
161
+ else VersionRange.full(admit_arbitrary=False)
162
+ )
163
+ reqs[name] = reqs.get(name, VersionRange.full()) & term
159
164
  for extra in req.extras:
160
- reqs[f"{name}[{extra}]"] = VersionRange.full()
165
+ reqs[f"{name}[{extra}]"] = VersionRange.full(admit_arbitrary=False)
161
166
  return reqs
162
167
 
163
168
 
@@ -138,9 +138,14 @@ def parse_requirements(
138
138
  )
139
139
  raise NotImplementedError(msg)
140
140
  name = canonicalize_name(req.name)
141
- reqs[name] = reqs.get(name, VersionRange.full()) & req.specifier.to_range()
141
+ term = (
142
+ req.specifier.to_range()
143
+ if req.specifier
144
+ else VersionRange.full(admit_arbitrary=False)
145
+ )
146
+ reqs[name] = reqs.get(name, VersionRange.full()) & term
142
147
  for extra in req.extras:
143
- reqs[f"{name}[{extra}]"] = VersionRange.full()
148
+ reqs[f"{name}[{extra}]"] = VersionRange.full(admit_arbitrary=False)
144
149
  return reqs
145
150
 
146
151
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nab-python"
3
- version = "0.0.6"
3
+ version = "0.0.8"
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.6",
23
- "nab-index==0.0.6",
22
+ "nab-resolver==0.0.8",
23
+ "nab-index==0.0.8",
24
24
  "tomli>=2.0",
25
25
  "tomli_w>=1.2",
26
26
  "build>=1.2",
@@ -33,12 +33,12 @@ import build
33
33
  import pyproject_hooks
34
34
  import tomli
35
35
 
36
- from .._vendor.packaging.requirements import Requirement
36
+ from nab_resolver.resolver import ResolutionError
37
+
38
+ from .._vendor.packaging.requirements import InvalidRequirement, Requirement
37
39
  from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
38
40
  from .._vendor.packaging.utils import canonicalize_name
39
41
  from .._vendor.packaging.version import InvalidVersion, Version
40
- from nab_resolver.resolver import ResolutionError
41
-
42
42
  from ..metadata import WheelMetadata
43
43
  from .env import BuildEnvError, NabBuildEnv
44
44
  from .errors import BuildBackendError
@@ -238,15 +238,19 @@ def _parse_metadata(metadata_path: Path) -> WheelMetadata:
238
238
  SpecifierSet(requires_python_raw) if requires_python_raw else None
239
239
  )
240
240
  except InvalidSpecifier as exc:
241
- msg = f"backend METADATA has invalid Requires-Python {requires_python_raw!r}: {exc}"
241
+ msg = (
242
+ f"backend METADATA has invalid Requires-Python "
243
+ f"{requires_python_raw!r}: {exc}"
244
+ )
242
245
  raise BuildBackendError(msg) from exc
243
246
 
244
- requires_dist: list[Requirement] = []
245
- for raw in msg_obj.get_all("Requires-Dist") or ():
246
- try:
247
- requires_dist.append(Requirement(raw))
248
- except (ValueError, TypeError): # noqa: PERF203
249
- logger.warning("skipping unparseable Requires-Dist: %s", raw)
247
+ try:
248
+ requires_dist: list[Requirement] = [
249
+ Requirement(raw) for raw in msg_obj.get_all("Requires-Dist") or ()
250
+ ]
251
+ except InvalidRequirement as exc:
252
+ msg = f"backend METADATA has an invalid Requires-Dist: {exc}"
253
+ raise BuildBackendError(msg) from exc
250
254
 
251
255
  provides_extra: list[str] = sorted(
252
256
  {
@@ -21,6 +21,7 @@ from nab_index.client import SdistFile, WheelFile
21
21
 
22
22
  from .._toml import tool_nab_section
23
23
  from .._vendor.packaging.pylock import Pylock, PylockValidationError
24
+ from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
24
25
  from .._vendor.packaging.utils import canonicalize_name
25
26
 
26
27
  if TYPE_CHECKING:
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
30
31
 
31
32
  from .._vendor.packaging.version import Version
32
33
  from ..lockfile import (
34
+ ArchivePin,
33
35
  IndexPin,
34
36
  LockInput,
35
37
  PinShape,
@@ -37,7 +39,7 @@ if TYPE_CHECKING:
37
39
  VcsPin,
38
40
  WheelArtifact,
39
41
  )
40
- from ..provider import DistPolicy, LocalSource, VcsSource
42
+ from ..provider import ArchiveSource, DistPolicy, LocalSource, VcsSource
41
43
 
42
44
 
43
45
  __all__ = [
@@ -95,6 +97,10 @@ class LockInputProvider(Protocol):
95
97
  """Return the configured VcsSource for ``canonical_name`` or None."""
96
98
  ...
97
99
 
100
+ def archive_source_for(self, canonical_name: str, /) -> ArchiveSource | None:
101
+ """Return the configured ArchiveSource for ``canonical_name`` or None."""
102
+ ...
103
+
98
104
  def vcs_pin_for(self, canonical_name: str, /) -> str | None:
99
105
  """Return the resolved 40-char SHA captured during materialisation."""
100
106
  ...
@@ -287,6 +293,12 @@ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a disti
287
293
  resolved_sha=provider.vcs_pin_for(canonical),
288
294
  )
289
295
  continue
296
+ archive_source = provider.archive_source_for(canonical)
297
+ if archive_source is not None:
298
+ lock_pins[canonical] = _archive_pin_from_source(
299
+ canonical, version, archive_source
300
+ )
301
+ continue
290
302
  lock_pins[canonical] = _index_pin_from_listing(
291
303
  provider, canonical, version, indexes
292
304
  )
@@ -339,6 +351,9 @@ def _forward_dependency_graph(
339
351
  for dep in extra_map.get(extra, {})
340
352
  )
341
353
  dep_names &= pinned
354
+ # An umbrella extra (pkg[all] pulling pkg[graphviz]) can name its own
355
+ # package; drop it so pkg is never an edge to itself.
356
+ dep_names.discard(canonical)
342
357
  if dep_names:
343
358
  graph[canonical] = tuple(sorted(dep_names))
344
359
  return graph
@@ -519,11 +534,27 @@ def require_artifact_hashes(lock_input: LockInput) -> None:
519
534
 
520
535
 
521
536
  def _common_requires_python(files: Iterable[WheelFile | SdistFile]) -> str | None:
522
- """Return a single Requires-Python value if all files agree, else ``None``."""
537
+ """Return the package-level Requires-Python value, or ``None``.
538
+
539
+ An artefact with no Requires-Python is unconstrained, so a single
540
+ such artefact leaves the whole package unconstrained. Otherwise the
541
+ value survives only when every artefact agrees.
542
+
543
+ A malformed value counts as unconstrained too, matching
544
+ ``excluded_by_python``, which admits a dist whose Requires-Python is
545
+ an unparseable specifier on any Python. So the lock writer always
546
+ parses a valid specifier or ``None``, and the pin is never
547
+ over-constrained by an artefact whose floor nab could not read.
548
+ """
523
549
  seen: set[str] = set()
524
550
  for f in files:
525
- if f.requires_python is not None:
526
- seen.add(f.requires_python)
551
+ if f.requires_python is None:
552
+ return None
553
+ try:
554
+ SpecifierSet(f.requires_python)
555
+ except InvalidSpecifier:
556
+ return None
557
+ seen.add(f.requires_python)
527
558
  if len(seen) == 1:
528
559
  return next(iter(seen))
529
560
  return None
@@ -593,3 +624,30 @@ def _vcs_pin_from_source(
593
624
  requested_revision=requested_revision,
594
625
  vcs_type=parsed.scheme,
595
626
  )
627
+
628
+
629
+ def _archive_pin_from_source(
630
+ canonical: str,
631
+ version: Version,
632
+ source: ArchiveSource,
633
+ ) -> ArchivePin:
634
+ """Build an :class:`ArchivePin` from an :class:`ArchiveSource`.
635
+
636
+ The URL, hashes, and subdirectory come from the source declaration,
637
+ which config parse validated (a hash is required), so the pin records
638
+ the exact archive the resolve used. The URL is stripped of any
639
+ credential userinfo, like every other pin, so a committed lockfile
640
+ never carries a token.
641
+ """
642
+ from nab_index.archive import ArchiveRequest
643
+
644
+ from ..lockfile import ArchivePin
645
+
646
+ request = ArchiveRequest.parse(source.url)
647
+ return ArchivePin(
648
+ name=canonical,
649
+ version=str(version),
650
+ url=_strip_userinfo(request.url),
651
+ hashes=request.hashes,
652
+ subdirectory=request.subdirectory or None,
653
+ )
@@ -21,6 +21,7 @@ import tomli_w
21
21
  from .._vendor.packaging.markers import Marker
22
22
  from .._vendor.packaging.pylock import (
23
23
  Package,
24
+ PackageArchive,
24
25
  PackageDirectory,
25
26
  PackageSdist,
26
27
  PackageVcs,
@@ -214,7 +215,7 @@ def _pin_to_package(
214
215
  lock_dir: Path,
215
216
  dependencies: list[dict[str, str]] | None = None,
216
217
  ) -> Package:
217
- from ..lockfile import IndexPin, LocalPin, VcsPin
218
+ from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
218
219
 
219
220
  if isinstance(pin, IndexPin):
220
221
  return Package(
@@ -267,6 +268,20 @@ def _pin_to_package(
267
268
  requested_revision=pin.requested_revision,
268
269
  ),
269
270
  )
271
+ if isinstance(pin, ArchivePin):
272
+ # An archive is content-pinned by its hash, so the version is
273
+ # stable and recorded (unlike the directory/VCS cases above).
274
+ return Package(
275
+ name=canonicalize_name(pin.name),
276
+ version=Version(pin.version),
277
+ marker=marker,
278
+ dependencies=dependencies,
279
+ archive=PackageArchive(
280
+ url=pin.url,
281
+ hashes=dict(pin.hashes),
282
+ subdirectory=pin.subdirectory,
283
+ ),
284
+ )
270
285
  msg = f"unknown pin shape: {pin!r}"
271
286
  raise TypeError(msg)
272
287
 
@@ -423,7 +438,7 @@ def _group_pins_by_pin(
423
438
 
424
439
  def _pin_discriminator(pin: PinShape) -> tuple:
425
440
  """Return a hashable key that identifies the source + version of ``pin``."""
426
- from ..lockfile import IndexPin, LocalPin, VcsPin
441
+ from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
427
442
 
428
443
  if isinstance(pin, IndexPin):
429
444
  return ("index", pin.version, pin.index)
@@ -447,6 +462,8 @@ def _pin_discriminator(pin: PinShape) -> tuple:
447
462
  pin.repo_url,
448
463
  pin.subdirectory or "",
449
464
  )
465
+ if isinstance(pin, ArchivePin):
466
+ return ("archive", pin.version, pin.url, pin.hashes, pin.subdirectory or "")
450
467
  msg = f"unknown pin shape: {pin!r}"
451
468
  raise TypeError(msg)
452
469
 
@@ -456,8 +473,9 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
456
473
 
457
474
  For :class:`IndexPin`, accumulates every distinct wheel filename
458
475
  across the contributing tuples and keeps the first non-``None``
459
- sdist. ``requires_python`` survives only when every tuple agreed,
460
- matching :func:`_common_requires_python`'s rule.
476
+ sdist. ``requires_python`` survives only when every tuple carried
477
+ the same value and none was unconstrained, matching
478
+ :func:`_common_requires_python`'s rule.
461
479
  Non-IndexPin shapes are already fully discriminated, so the first
462
480
  pin is returned unchanged.
463
481
  """
@@ -469,16 +487,21 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
469
487
  seen_wheels: dict[str, WheelArtifact] = {}
470
488
  sdist = head.sdist
471
489
  requires_python_set: set[str] = set()
490
+ any_unconstrained = False
472
491
  for pin in pins:
473
492
  assert isinstance(pin, IndexPin)
474
493
  for wheel in pin.wheels:
475
494
  seen_wheels.setdefault(wheel.filename, wheel)
476
495
  if sdist is None and pin.sdist is not None:
477
496
  sdist = pin.sdist
478
- if pin.requires_python is not None:
497
+ if pin.requires_python is None:
498
+ any_unconstrained = True
499
+ else:
479
500
  requires_python_set.add(pin.requires_python)
480
501
  requires_python = (
481
- next(iter(requires_python_set)) if len(requires_python_set) == 1 else None
502
+ next(iter(requires_python_set))
503
+ if not any_unconstrained and len(requires_python_set) == 1
504
+ else None
482
505
  )
483
506
  return IndexPin(
484
507
  name=head.name,
@@ -38,8 +38,11 @@ def write_requirements_with_hashes(
38
38
  accepts. Local and VCS pins are emitted as ``name @ <url>`` lines
39
39
  without hashes (pip does not hash-check those forms); an editable
40
40
  local pin renders as ``-e <url>`` and a ``subdirectory`` as a
41
- ``#subdirectory=`` fragment. Returns the text and, when
42
- ``output_path`` is provided, atomically writes it.
41
+ ``#subdirectory=`` fragment. An archive pin is a third form, ``name @
42
+ <url>#sha256=...``, carrying its hash in the fragment;
43
+ :func:`require_artifact_hashes` skips it because that hash is guaranteed
44
+ at config parse. Returns the text and, when ``output_path`` is provided,
45
+ atomically writes it.
43
46
  """
44
47
  require_artifact_hashes(lock_input)
45
48
  return _render_requirements(lock_input, with_hashes=True, output_path=output_path)
@@ -51,8 +54,8 @@ def write_requirements_without_hashes(
51
54
  """Render ``lock_input`` as a plain ``name==version`` list.
52
55
 
53
56
  Same shape as :func:`write_requirements_with_hashes` but without
54
- the ``--hash=sha256:...`` lines. Local and VCS pins render the
55
- same in both variants. Returns the text and, when ``output_path``
57
+ the ``--hash=sha256:...`` lines. Local, VCS, and archive pins render
58
+ the same in both variants. Returns the text and, when ``output_path``
56
59
  is provided, atomically writes it.
57
60
  """
58
61
  return _render_requirements(lock_input, with_hashes=False, output_path=output_path)
@@ -94,7 +97,7 @@ def _render_per_tuple_requirements(lock_input: LockInput, *, with_hashes: bool)
94
97
 
95
98
  def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str]:
96
99
  """Render a flat ``{name: pin}`` mapping in alphabetical order."""
97
- from ..lockfile import IndexPin, LocalPin, VcsPin
100
+ from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
98
101
 
99
102
  lines: list[str] = []
100
103
  for canonical in sorted(pins):
@@ -112,6 +115,14 @@ def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str
112
115
  lines.append(f"{pin.name} @ {url}")
113
116
  elif isinstance(pin, VcsPin):
114
117
  lines.append(f"{pin.name} @ {pin.repo_url}")
118
+ elif isinstance(pin, ArchivePin):
119
+ # The hash is the archive's identity, so carry it (and any
120
+ # subdirectory) in the fragment for a reproducible, hash-checkable
121
+ # install line, mirroring how VcsPin pins its commit in the URL.
122
+ fragment = "&".join(f"{algo}={digest}" for algo, digest in pin.hashes)
123
+ if pin.subdirectory is not None:
124
+ fragment += f"&subdirectory={quote(pin.subdirectory, safe='/')}"
125
+ lines.append(f"{pin.name} @ {pin.url}#{fragment}")
115
126
  else: # pragma: no cover - exhaustive
116
127
  msg = f"unknown pin shape: {pin!r}"
117
128
  raise TypeError(msg)
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from ._vendor.packaging.ranges import VersionRange
8
+
7
9
  if TYPE_CHECKING:
8
10
  from collections.abc import Mapping
9
11
 
10
12
  from nab_resolver.types import Incompatibility, RangeProtocol
11
13
 
12
- from ._vendor.packaging.ranges import VersionRange
13
14
  from ._vendor.packaging.specifiers import SpecifierSet
14
15
  from ._vendor.packaging.version import Version
15
16
 
@@ -47,12 +48,21 @@ class PackagingProvider:
47
48
  return version
48
49
  return None
49
50
 
51
+ def has_satisfying_version(
52
+ self, package: str, version_range: RangeProtocol[Version]
53
+ ) -> bool:
54
+ """Report whether any available version falls in the range."""
55
+ return any(v in version_range for v in self._get_versions(package))
56
+
50
57
  def get_dependencies(
51
58
  self, package: str, version: Version
52
59
  ) -> dict[str, VersionRange]:
53
60
  """Convert SpecifierSet deps to VersionRange deps."""
54
61
  raw = self._packages.get(package, {}).get(version, {})
55
- return {dep: spec.to_range() for dep, spec in raw.items()}
62
+ return {
63
+ dep: (spec.to_range() if spec else VersionRange.full(admit_arbitrary=False))
64
+ for dep, spec in raw.items()
65
+ }
56
66
 
57
67
  _CONFLICT_THRESHOLD = 5
58
68
 
@@ -96,3 +106,15 @@ class PackagingProvider:
96
106
  def consume_force_backtrack_targets(self) -> list[str]:
97
107
  """No force-backtrack signal from this in-memory provider."""
98
108
  return []
109
+
110
+ def widen_decision(self, package: str, version: Version) -> VersionRange | None:
111
+ """No widening: dependency clauses keep the exact decided version."""
112
+ del package, version
113
+ return None
114
+
115
+ def narrow_for_display(
116
+ self, package: str, constraint: RangeProtocol[Version]
117
+ ) -> RangeProtocol[Version]:
118
+ """Identity: constraints render as stored."""
119
+ del package
120
+ return constraint
@@ -43,26 +43,43 @@ def choose_extra_version(
43
43
  _, _, normalized = provider.split_and_normalize(base)
44
44
  version_list = provider.fetch_versions(base)
45
45
  all_versions = provider.versions_only(normalized, version_list)
46
- candidates = list(version_range.filter(all_versions))
47
46
 
48
- # Filter by base's positive range so we don't pick a proxy version
49
- # that would force base==V into a known-conflicting state.
47
+ # Filter by the base's positive range so we don't pick a proxy version
48
+ # that would force base==V into a known-conflicting state. Intersect
49
+ # rather than test membership: the base's range carries the pre-release
50
+ # admission granted by the requirement that named the extra, while the
51
+ # proxy's own range is built full.
50
52
  base_range = provider.solution_ranges.get(normalized)
51
- excluded_by_base: list[Version] = []
52
- if base_range is not None:
53
- kept: list[Version] = []
54
- for v in candidates:
55
- if v in base_range:
56
- kept.append(v)
57
- else:
58
- excluded_by_base.append(v)
59
- candidates = kept
53
+ if base_range is None:
54
+ logger.debug(
55
+ "no base range for %s; base admission cannot be applied to %s",
56
+ normalized,
57
+ package,
58
+ )
59
+ candidates = list(version_range.filter(all_versions))
60
+ else:
61
+ candidates = list((version_range & base_range).filter(all_versions))
60
62
 
61
63
  if provider.wants_lowest(normalized):
62
64
  candidates = list(reversed(candidates))
63
65
 
64
66
  chosen = _pick_in_mode(provider, base, extra, candidates)
65
- if chosen is None and excluded_by_base and base_range is not None:
67
+ # Enumerate pre-releases too: default filtering buffers a pre-release
68
+ # behind any matching final and would drop one that the base's bounds
69
+ # exclude, so it would never be recorded and the proxy would keep a
70
+ # permanent NO_VERSIONS clause past the backjump lifting the base
71
+ # decision. Membership below is bounds-only, so the blocks stay sound.
72
+ if (
73
+ chosen is None
74
+ and base_range is not None
75
+ and (
76
+ excluded_by_base := [
77
+ v
78
+ for v in version_range.filter(all_versions, prereleases=True)
79
+ if v not in base_range
80
+ ]
81
+ )
82
+ ):
66
83
  _record_base_range_blocks(
67
84
  provider, package, normalized, base_range, excluded_by_base
68
85
  )
@@ -77,23 +94,15 @@ def _pick_in_mode(
77
94
  ) -> Version | None:
78
95
  """Pick a candidate honoring ``ExtrasMode``.
79
96
 
80
- Fetches base metadata so an extraction failure (unparseable
81
- PKG-INFO, or an sdist build the policy disallows) becomes a
82
- candidate skip instead of a fatal error during the later
83
- dependency fetch. BACKTRACK mode additionally checks
84
- ``Provides-Extra`` for transitive extras.
85
-
86
- Missing-metadata cases (no PEP 658, no sdist) skip transitive
87
- extras but fall through for user-requested ones; mock test
88
- coordinators rely on this.
97
+ Fetches base metadata so an extraction failure (unparseable PKG-INFO,
98
+ a disallowed sdist build, or no metadata source at all) skips the
99
+ candidate rather than raising later, when the proxy refetches the base
100
+ to expand the extra. This applies to user-requested extras too, since
101
+ the proxy always needs the base metadata. BACKTRACK mode additionally
102
+ checks ``Provides-Extra`` for transitive extras.
89
103
  """
90
104
  # Late import: ``pypi`` imports this module at module load.
91
- from ..provider import (
92
- ExtrasMode,
93
- MetadataError,
94
- UnsupportedSdistError,
95
- _normalize_extra,
96
- )
105
+ from ..provider import ExtrasMode, MetadataError, _normalize_extra
97
106
 
98
107
  _, _, normalized = provider.split_and_normalize(base)
99
108
  is_user = (normalized, extra) in provider.root_extras
@@ -103,12 +112,8 @@ def _pick_in_mode(
103
112
  continue
104
113
  try:
105
114
  provider.get_dependencies(base, version)
106
- except UnsupportedSdistError:
107
- continue
108
115
  except MetadataError:
109
- if not is_user or provider.has_invalid_metadata(normalized, version):
110
- continue
111
- return version
116
+ continue
112
117
  if is_user or not backtrack:
113
118
  return version
114
119
  metadata = provider.metadata_cache.get((normalized, version))
@@ -122,6 +127,32 @@ def _pick_in_mode(
122
127
  return None
123
128
 
124
129
 
130
+ def version_provides_extra(
131
+ provider: Provider,
132
+ base: str,
133
+ extra: str,
134
+ version: Version,
135
+ ) -> bool:
136
+ """Whether ``version`` of ``base`` declares ``extra`` and yields metadata here.
137
+
138
+ Honoring a cross-tuple preference for a ``base[extra]`` proxy is only
139
+ safe when the preferred version both provides the extra and has
140
+ extractable metadata in this tuple.
141
+ """
142
+ # Late import: provider imports this module at module load.
143
+ from ..provider import MetadataError, _normalize_extra
144
+
145
+ _, _, normalized = provider.split_and_normalize(base)
146
+ try:
147
+ provider.get_dependencies(base, version)
148
+ except MetadataError:
149
+ return False
150
+
151
+ metadata = provider.metadata_cache[(normalized, version)]
152
+ provided = {_normalize_extra(e) for e in metadata.provides_extra}
153
+ return extra in provided
154
+
155
+
125
156
  def _record_base_range_blocks(
126
157
  provider: Provider,
127
158
  proxy_pkg: str,
@@ -50,6 +50,14 @@ def fetch_versions(provider: Provider, package: str) -> list[tuple[Version, Dist
50
50
  provider.versions_cache[normalized] = result
51
51
  return result
52
52
 
53
+ archive = provider.archive_sources.get(normalized)
54
+ if archive is not None:
55
+ # The download-and-verify guards are gated everywhere; only the
56
+ # post-extraction success tail needs the tar data filter (see sources.py).
57
+ result = provider.materialize_archive_source(normalized, archive)
58
+ provider.versions_cache[normalized] = result # pragma: no cover
59
+ return result # pragma: no cover
60
+
53
61
  files = provider.coordinator.index.get_listing(normalized)
54
62
  if files is None:
55
63
  event = provider.coordinator.request_listing(normalized)
@@ -572,13 +580,17 @@ def prefetch_new_deps(provider: Provider, deps: Mapping[str, VersionRange]) -> N
572
580
  candidate. This deepens the prefetch cascade so metadata is
573
581
  ready before the resolver asks for it.
574
582
 
575
- Local and VCS sources are skipped; they have no PyPI listing
576
- and the materialise path in ``fetch_versions`` will surface
577
- them when the resolver asks.
583
+ Local, VCS, and archive sources are skipped; they have no PyPI
584
+ listing and the materialise path in ``fetch_versions`` will
585
+ surface them when the resolver asks.
578
586
  """
579
587
  for dep in deps:
580
588
  _, _, normalized = provider.split_and_normalize(dep)
581
- if normalized in provider.local_sources or normalized in provider.vcs_sources:
589
+ if (
590
+ normalized in provider.local_sources
591
+ or normalized in provider.vcs_sources
592
+ or normalized in provider.archive_sources
593
+ ):
582
594
  continue
583
595
  if normalized not in provider.versions_cache:
584
596
  # Listing not cached: request it. When it arrives,