nab-python 0.0.1a0__tar.gz → 0.0.3__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 (176) hide show
  1. nab_python-0.0.3/.gitignore +20 -0
  2. nab_python-0.0.3/LICENSE +1 -0
  3. {nab_python-0.0.1a0 → nab_python-0.0.3}/PKG-INFO +4 -4
  4. nab_python-0.0.3/benchmarks/README.md +53 -0
  5. nab_python-0.0.3/benchmarks/_profile_runner.py +120 -0
  6. nab_python-0.0.3/benchmarks/cache/.gitignore +1 -0
  7. nab_python-0.0.3/benchmarks/canary.py +459 -0
  8. nab_python-0.0.3/benchmarks/canary_results/.gitignore +2 -0
  9. nab_python-0.0.3/benchmarks/compare.py +132 -0
  10. nab_python-0.0.3/benchmarks/results/.gitignore +2 -0
  11. nab_python-0.0.3/benchmarks/scenarios/ai-stack-lowest-direct.toml +858 -0
  12. nab_python-0.0.3/benchmarks/scenarios/ai-stack-lowest.toml +858 -0
  13. nab_python-0.0.3/benchmarks/scenarios/ai-stack.toml +808 -0
  14. nab_python-0.0.3/benchmarks/scenarios/airflow-lowest-direct.toml +258 -0
  15. nab_python-0.0.3/benchmarks/scenarios/airflow-lowest.toml +258 -0
  16. nab_python-0.0.3/benchmarks/scenarios/airflow.toml +237 -0
  17. nab_python-0.0.3/benchmarks/scenarios/big-packages-lowest-direct.toml +78 -0
  18. nab_python-0.0.3/benchmarks/scenarios/big-packages-lowest.toml +78 -0
  19. nab_python-0.0.3/benchmarks/scenarios/big-packages.toml +67 -0
  20. nab_python-0.0.3/benchmarks/scenarios/cross-tracker-lowest-direct.toml +110 -0
  21. nab_python-0.0.3/benchmarks/scenarios/cross-tracker-lowest.toml +110 -0
  22. nab_python-0.0.3/benchmarks/scenarios/cross-tracker.toml +101 -0
  23. nab_python-0.0.3/benchmarks/scenarios/ecosystem-lowest-direct.toml +531 -0
  24. nab_python-0.0.3/benchmarks/scenarios/ecosystem-lowest.toml +531 -0
  25. nab_python-0.0.3/benchmarks/scenarios/ecosystem.toml +490 -0
  26. nab_python-0.0.3/benchmarks/scenarios/forums-lowest-direct.toml +1349 -0
  27. nab_python-0.0.3/benchmarks/scenarios/forums-lowest.toml +1349 -0
  28. nab_python-0.0.3/benchmarks/scenarios/forums.toml +1251 -0
  29. nab_python-0.0.3/benchmarks/scenarios/pdm-lowest-direct.toml +336 -0
  30. nab_python-0.0.3/benchmarks/scenarios/pdm-lowest.toml +336 -0
  31. nab_python-0.0.3/benchmarks/scenarios/pdm.toml +315 -0
  32. nab_python-0.0.3/benchmarks/scenarios/pip-lowest-direct.toml +2129 -0
  33. nab_python-0.0.3/benchmarks/scenarios/pip-lowest.toml +2129 -0
  34. nab_python-0.0.3/benchmarks/scenarios/pip.toml +2024 -0
  35. nab_python-0.0.3/benchmarks/scenarios/poetry-lowest-direct.toml +545 -0
  36. nab_python-0.0.3/benchmarks/scenarios/poetry-lowest.toml +545 -0
  37. nab_python-0.0.3/benchmarks/scenarios/poetry.toml +504 -0
  38. nab_python-0.0.3/benchmarks/scenarios/pytorch-lowest-direct.toml +175 -0
  39. nab_python-0.0.3/benchmarks/scenarios/pytorch-lowest.toml +175 -0
  40. nab_python-0.0.3/benchmarks/scenarios/pytorch.toml +163 -0
  41. nab_python-0.0.3/benchmarks/scenarios/quick-lowest-direct.toml +13 -0
  42. nab_python-0.0.3/benchmarks/scenarios/quick-lowest.toml +13 -0
  43. nab_python-0.0.3/benchmarks/scenarios/quick.toml +11 -0
  44. nab_python-0.0.3/benchmarks/scenarios/rip-lowest-direct.toml +121 -0
  45. nab_python-0.0.3/benchmarks/scenarios/rip-lowest.toml +121 -0
  46. nab_python-0.0.3/benchmarks/scenarios/rip.toml +109 -0
  47. nab_python-0.0.3/benchmarks/scenarios/universal.toml +335 -0
  48. nab_python-0.0.3/benchmarks/scenarios/unsupported-lowest-direct.toml +215 -0
  49. nab_python-0.0.3/benchmarks/scenarios/unsupported-lowest.toml +215 -0
  50. nab_python-0.0.3/benchmarks/scenarios/unsupported.toml +204 -0
  51. nab_python-0.0.3/benchmarks/scenarios/uv-lowest-direct.toml +1764 -0
  52. nab_python-0.0.3/benchmarks/scenarios/uv-lowest.toml +1764 -0
  53. nab_python-0.0.3/benchmarks/scenarios/uv.toml +1640 -0
  54. nab_python-0.0.3/benchmarks/scenarios.py +669 -0
  55. nab_python-0.0.3/benchmarks/strategy_sweep.py +361 -0
  56. nab_python-0.0.3/benchmarks/strategy_sweep_results/summary.before-build-policy.json +21937 -0
  57. nab_python-0.0.3/benchmarks/strategy_sweep_results/summary.json +21931 -0
  58. nab_python-0.0.3/benchmarks/strategy_sweep_results/summary.session28-baseline.json +21970 -0
  59. nab_python-0.0.3/benchmarks/strategy_sweep_results_fix12/summary.json +21965 -0
  60. nab_python-0.0.3/benchmarks/strategy_sweep_summary.py +147 -0
  61. nab_python-0.0.3/benchmarks/universal_scenarios.py +258 -0
  62. nab_python-0.0.3/benchmarks/universal_summary.py +49 -0
  63. {nab_python-0.0.1a0 → nab_python-0.0.3}/pyproject.toml +4 -4
  64. nab_python-0.0.3/src/nab_python/__init__.py +1 -0
  65. nab_python-0.0.3/src/nab_python/_build/__init__.py +1 -0
  66. nab_python-0.0.3/src/nab_python/_build/env.py +368 -0
  67. nab_python-0.0.3/src/nab_python/_build/errors.py +17 -0
  68. nab_python-0.0.3/src/nab_python/_build/runner.py +254 -0
  69. nab_python-0.0.3/src/nab_python/_lockfile/__init__.py +1 -0
  70. nab_python-0.0.3/src/nab_python/_lockfile/builder.py +424 -0
  71. nab_python-0.0.3/src/nab_python/_lockfile/disjointness.py +207 -0
  72. nab_python-0.0.3/src/nab_python/_lockfile/pylock.py +412 -0
  73. nab_python-0.0.3/src/nab_python/_lockfile/requirements.py +121 -0
  74. nab_python-0.0.3/src/nab_python/_packaging_provider.py +98 -0
  75. nab_python-0.0.3/src/nab_python/_provider/__init__.py +1 -0
  76. nab_python-0.0.3/src/nab_python/_provider/build_remote.py +95 -0
  77. nab_python-0.0.3/src/nab_python/_provider/extras.py +231 -0
  78. nab_python-0.0.3/src/nab_python/_provider/listing.py +442 -0
  79. nab_python-0.0.3/src/nab_python/_provider/lookahead.py +156 -0
  80. nab_python-0.0.3/src/nab_python/_provider/metadata_resolver.py +454 -0
  81. nab_python-0.0.3/src/nab_python/_provider/priority.py +174 -0
  82. nab_python-0.0.3/src/nab_python/_provider/sources.py +225 -0
  83. nab_python-0.0.3/src/nab_python/_testing/__init__.py +1 -0
  84. nab_python-0.0.3/src/nab_python/_testing/coordinator_fake.py +243 -0
  85. nab_python-0.0.3/src/nab_python/_vcs_admission.py +185 -0
  86. nab_python-0.0.3/src/nab_python/_vendor/__init__.py +6 -0
  87. nab_python-0.0.3/src/nab_python/_vendor/packaging/LICENSE +3 -0
  88. nab_python-0.0.3/src/nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
  89. nab_python-0.0.3/src/nab_python/_vendor/packaging/LICENSE.BSD +23 -0
  90. nab_python-0.0.3/src/nab_python/_vendor/packaging/PROVENANCE.md +73 -0
  91. nab_python-0.0.3/src/nab_python/_vendor/packaging/__init__.py +15 -0
  92. nab_python-0.0.3/src/nab_python/_vendor/packaging/_elffile.py +108 -0
  93. nab_python-0.0.3/src/nab_python/_vendor/packaging/_manylinux.py +265 -0
  94. nab_python-0.0.3/src/nab_python/_vendor/packaging/_musllinux.py +88 -0
  95. nab_python-0.0.3/src/nab_python/_vendor/packaging/_parser.py +394 -0
  96. nab_python-0.0.3/src/nab_python/_vendor/packaging/_range_utils.py +773 -0
  97. nab_python-0.0.3/src/nab_python/_vendor/packaging/_structures.py +33 -0
  98. nab_python-0.0.3/src/nab_python/_vendor/packaging/_tokenizer.py +196 -0
  99. nab_python-0.0.3/src/nab_python/_vendor/packaging/_version_utils.py +37 -0
  100. nab_python-0.0.3/src/nab_python/_vendor/packaging/dependency_groups.py +302 -0
  101. nab_python-0.0.3/src/nab_python/_vendor/packaging/direct_url.py +325 -0
  102. nab_python-0.0.3/src/nab_python/_vendor/packaging/errors.py +94 -0
  103. nab_python-0.0.3/src/nab_python/_vendor/packaging/licenses/__init__.py +186 -0
  104. nab_python-0.0.3/src/nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
  105. nab_python-0.0.3/src/nab_python/_vendor/packaging/markers.py +506 -0
  106. nab_python-0.0.3/src/nab_python/_vendor/packaging/metadata.py +964 -0
  107. nab_python-0.0.3/src/nab_python/_vendor/packaging/pylock.py +910 -0
  108. nab_python-0.0.3/src/nab_python/_vendor/packaging/ranges.py +1143 -0
  109. nab_python-0.0.3/src/nab_python/_vendor/packaging/requirements.py +132 -0
  110. nab_python-0.0.3/src/nab_python/_vendor/packaging/specifiers.py +1324 -0
  111. nab_python-0.0.3/src/nab_python/_vendor/packaging/tags.py +929 -0
  112. nab_python-0.0.3/src/nab_python/_vendor/packaging/utils.py +296 -0
  113. nab_python-0.0.3/src/nab_python/_vendor/packaging/version.py +1230 -0
  114. nab_python-0.0.3/src/nab_python/build_backend.py +184 -0
  115. nab_python-0.0.3/src/nab_python/config.py +845 -0
  116. nab_python-0.0.3/src/nab_python/download.py +177 -0
  117. nab_python-0.0.3/src/nab_python/fetch.py +828 -0
  118. nab_python-0.0.3/src/nab_python/lockfile.py +268 -0
  119. nab_python-0.0.3/src/nab_python/metadata.py +145 -0
  120. nab_python-0.0.3/src/nab_python/provider.py +1249 -0
  121. nab_python-0.0.3/src/nab_python/py.typed +0 -0
  122. nab_python-0.0.3/src/nab_python/requirements_file.py +209 -0
  123. nab_python-0.0.3/src/nab_python/resolve.py +512 -0
  124. nab_python-0.0.3/src/nab_python/universal/__init__.py +1 -0
  125. nab_python-0.0.3/src/nab_python/universal/matrix.py +235 -0
  126. nab_python-0.0.3/src/nab_python/universal/provider.py +214 -0
  127. nab_python-0.0.3/src/nab_python/universal/reresolve.py +310 -0
  128. nab_python-0.0.3/src/nab_python/universal/resolve.py +550 -0
  129. nab_python-0.0.3/src/nab_python/universal/validate.py +439 -0
  130. nab_python-0.0.3/src/nab_python/universal/wheel_selection.py +328 -0
  131. nab_python-0.0.3/src/nab_python/workspace.py +214 -0
  132. nab_python-0.0.3/tests/__init__.py +1 -0
  133. nab_python-0.0.3/tests/property_python/__init__.py +0 -0
  134. nab_python-0.0.3/tests/property_python/strategies.py +205 -0
  135. nab_python-0.0.3/tests/property_python/test_extras_pep685.py +288 -0
  136. nab_python-0.0.3/tests/property_python/test_lockfile_pep751.py +263 -0
  137. nab_python-0.0.3/tests/property_python/test_marker_overlay.py +135 -0
  138. nab_python-0.0.3/tests/property_python/test_multi_index_pep503.py +203 -0
  139. nab_python-0.0.3/tests/property_python/test_resolver_pep440.py +189 -0
  140. nab_python-0.0.3/tests/ruff.toml +30 -0
  141. nab_python-0.0.3/tests/test_async_transports.py +378 -0
  142. nab_python-0.0.3/tests/test_build_backend.py +360 -0
  143. nab_python-0.0.3/tests/test_build_runner.py +789 -0
  144. nab_python-0.0.3/tests/test_cache.py +260 -0
  145. nab_python-0.0.3/tests/test_cached_client.py +687 -0
  146. nab_python-0.0.3/tests/test_config.py +1216 -0
  147. nab_python-0.0.3/tests/test_download.py +294 -0
  148. nab_python-0.0.3/tests/test_fetch.py +1153 -0
  149. nab_python-0.0.3/tests/test_local_index.py +320 -0
  150. nab_python-0.0.3/tests/test_lockfile.py +2071 -0
  151. nab_python-0.0.3/tests/test_metadata.py +72 -0
  152. nab_python-0.0.3/tests/test_multi_index.py +269 -0
  153. nab_python-0.0.3/tests/test_provider.py +4581 -0
  154. nab_python-0.0.3/tests/test_requirements_file.py +320 -0
  155. nab_python-0.0.3/tests/test_resolve.py +1165 -0
  156. nab_python-0.0.3/tests/test_resolver_packaging.py +503 -0
  157. nab_python-0.0.3/tests/test_vcs.py +464 -0
  158. nab_python-0.0.3/tests/test_vcs_admission.py +283 -0
  159. nab_python-0.0.3/tests/test_workspace.py +301 -0
  160. nab_python-0.0.3/tests/universal/__init__.py +0 -0
  161. nab_python-0.0.3/tests/universal/property_universal/__init__.py +0 -0
  162. nab_python-0.0.3/tests/universal/property_universal/strategies.py +41 -0
  163. nab_python-0.0.3/tests/universal/property_universal/test_alignment.py +229 -0
  164. nab_python-0.0.3/tests/universal/property_universal/test_matrix.py +302 -0
  165. nab_python-0.0.3/tests/universal/property_universal/test_provider_pep425.py +250 -0
  166. nab_python-0.0.3/tests/universal/property_universal/test_validate.py +143 -0
  167. nab_python-0.0.3/tests/universal/test_matrix.py +258 -0
  168. nab_python-0.0.3/tests/universal/test_reresolve.py +391 -0
  169. nab_python-0.0.3/tests/universal/test_resolve.py +672 -0
  170. nab_python-0.0.3/tests/universal/test_universal_provider.py +514 -0
  171. nab_python-0.0.3/tests/universal/test_validate.py +936 -0
  172. nab_python-0.0.3/tests/universal/test_wheel_selection.py +371 -0
  173. nab_python-0.0.1a0/LICENSE +0 -21
  174. nab_python-0.0.1a0/src/nab_python/__init__.py +0 -3
  175. {nab_python-0.0.1a0 → nab_python-0.0.3}/README.md +0 -0
  176. {nab_python-0.0.1a0/src/nab_python → nab_python-0.0.3/src/nab_python/_vendor/packaging}/py.typed +0 -0
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .hypothesis/
8
+ .coverage*
9
+ .ruff_cache/
10
+ .hatch/
11
+ docs/_build/
12
+
13
+ *token*.txt
14
+ *.pypirc
15
+ .pypirc
16
+
17
+ # Profiling artefacts. By convention these live in `profiling/` at
18
+ # the workspace root and never inside `docs/`, so the Sphinx build
19
+ # never has to scan them.
20
+ profiling/
@@ -0,0 +1 @@
1
+ ../LICENSE
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nab-python
3
- Version: 0.0.1a0
3
+ Version: 0.0.3
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/
7
7
  Project-URL: Issues, https://github.com/notatallshaw/nab/issues
8
8
  Project-URL: Source, https://github.com/notatallshaw/nab
9
- Project-URL: Changelog, https://github.com/notatallshaw/nab/blob/main/CHANGELOG.md
9
+ Project-URL: Changelog, https://github.com/notatallshaw/nab/releases
10
10
  Author-email: Damian Shaw <damian.peter.shaw@gmail.com>
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
@@ -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.1a0
24
- Requires-Dist: nab-resolver==0.0.1a0
23
+ Requires-Dist: nab-index==0.0.3
24
+ Requires-Dist: nab-resolver==0.0.3
25
25
  Requires-Dist: pyproject-hooks>=1.2
26
26
  Requires-Dist: tomli-w>=1.2
27
27
  Requires-Dist: tomli>=2.0
@@ -0,0 +1,53 @@
1
+ # Benchmarks
2
+
3
+ nab ships two benchmark suites that exercise the single-environment
4
+ and the universal resolver against real-world scenarios.
5
+
6
+ ## Single-environment scenarios
7
+
8
+ `nab-python/benchmarks/scenarios.py` runs scenarios drawn from
9
+ real-world resolver issues across pip, uv, poetry, pex, and
10
+ pip-tools. Scenario TOML files live under
11
+ `nab-python/benchmarks/scenarios/`.
12
+
13
+ ```bash
14
+ python nab-python/benchmarks/scenarios.py
15
+ ```
16
+
17
+ The runner records wall-clock, decision-count, and round-count
18
+ metrics, and writes a JSON summary under
19
+ `nab-python/benchmarks/results/<commit>/`.
20
+
21
+ ## Universal-resolution scenarios
22
+
23
+ `nab-python/benchmarks/universal_scenarios.py` runs
24
+ universal-resolution scenarios sourced from public uv/poetry/pex
25
+ slowness reports. The cases stress cross-tuple alignment, marker
26
+ divergence, and pre-release handling.
27
+
28
+ ```bash
29
+ python nab-python/benchmarks/universal_scenarios.py
30
+ python nab-python/benchmarks/universal_summary.py
31
+ ```
32
+
33
+ `universal_summary.py` walks the latest results directory and
34
+ prints a markdown table.
35
+
36
+ ## Scenario shape
37
+
38
+ Each scenario is a top-level TOML table keyed by name, with at least
39
+ `requirements` and a fixed `datetime` (used as the
40
+ `uploaded-prior-to` cutoff). Optional knobs include constraints,
41
+ marker overlay, dist policy, and build policy.
42
+
43
+ ## What the suites cover
44
+
45
+ * Tight version-cluster cases (e.g. boto3, awscli).
46
+ * Conflict graphs that have hit pip's default resolver budget
47
+ (numpy/scipy/scikit-learn matrices).
48
+ * Universal-mode fork-explosion cases (xinference, vllm,
49
+ ultralytics, copick).
50
+
51
+ The suites are diagnostic harnesses that flag regressions on pull
52
+ requests. Walltime is noisy; decision count and round count are
53
+ the load-bearing numbers.
@@ -0,0 +1,120 @@
1
+ """Single-scenario profiling runner.
2
+
3
+ Loads one scenario from the benchmark TOML files and runs the resolver
4
+ once. Designed to be wrapped by ``python -m profiling.sampling run``.
5
+
6
+ Usage:
7
+ .venv-3.15/bin/python -m profiling.sampling run -r 5khz \
8
+ --flamegraph -o profile.html \
9
+ nab-python/benchmarks/_profile_runner.py <scenario>
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if sys.version_info >= (3, 11):
21
+ import tomllib
22
+ else:
23
+ import tomli as tomllib
24
+
25
+ from nab_index.httpx_async_transport import HttpxAsyncTransport
26
+ from nab_index.multi_index import IndexConfig
27
+ from nab_python._vendor.packaging.ranges import VersionRange
28
+ from nab_python._vendor.packaging.requirements import Requirement
29
+ from nab_python._vendor.packaging.utils import canonicalize_name
30
+ from nab_python.fetch import (
31
+ DEFAULT_INDEX_NAME,
32
+ DEFAULT_INDEX_URL,
33
+ FetchCoordinator,
34
+ )
35
+ from nab_python.provider import DistPolicy, Provider
36
+ from nab_resolver.resolver import Resolver
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Iterable
40
+
41
+ BENCHMARKS_DIR = Path(__file__).parent
42
+ SCENARIOS_DIR = BENCHMARKS_DIR / "scenarios"
43
+ CACHE_DIR = BENCHMARKS_DIR / "cache"
44
+ DEFAULT_INDEXES = (IndexConfig(DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL),)
45
+
46
+
47
+ def parse_requirements(strs: Iterable[str]) -> dict[str, VersionRange]:
48
+ out: dict[str, VersionRange] = {}
49
+ for r in strs:
50
+ req = Requirement(r)
51
+ name = canonicalize_name(req.name)
52
+ vi = req.specifier.to_range()
53
+ if vi is not None:
54
+ out[name] = vi
55
+ for ex in req.extras:
56
+ out[f"{name}[{ex}]"] = VersionRange.full()
57
+ return out
58
+
59
+
60
+ def find_scenario(name: str) -> dict[str, Any] | None:
61
+ for p in SCENARIOS_DIR.glob("*.toml"):
62
+ with p.open("rb") as f:
63
+ data = tomllib.load(f)
64
+ if name in data:
65
+ return data[name]
66
+ return None
67
+
68
+
69
+ def main() -> None:
70
+ name = sys.argv[1]
71
+ scn = find_scenario(name)
72
+ if scn is None:
73
+ print(f"scenario {name!r} not found", file=sys.stderr)
74
+ sys.exit(2)
75
+ reqs = parse_requirements(scn["requirements"])
76
+ constraints = (
77
+ parse_requirements(scn.get("constraints", []))
78
+ if scn.get("constraints")
79
+ else None
80
+ )
81
+ py = scn["python_version"]
82
+ dt = scn.get("datetime")
83
+ upload = datetime.fromisoformat(dt).replace(tzinfo=timezone.utc) if dt else None
84
+ with FetchCoordinator(
85
+ HttpxAsyncTransport(),
86
+ indexes=list(DEFAULT_INDEXES),
87
+ cache_dir=CACHE_DIR,
88
+ ) as coord:
89
+ provider = Provider(
90
+ coord,
91
+ python_version=py,
92
+ root_requirements=reqs,
93
+ uploaded_prior_to=upload,
94
+ dist_policy=DistPolicy.PREFER_BINARY,
95
+ )
96
+ resolver = Resolver(
97
+ provider,
98
+ range_type=VersionRange,
99
+ root_version="0",
100
+ max_iterations=200_000,
101
+ )
102
+ t0 = time.monotonic()
103
+ try:
104
+ resolver.resolve(reqs, constraints=constraints)
105
+ elapsed = time.monotonic() - t0
106
+ print(
107
+ f"{name}: OK in {elapsed:.2f}s, "
108
+ f"{resolver.stats.decisions} decisions, "
109
+ f"{resolver.stats.conflicts} conflicts"
110
+ )
111
+ except Exception as exc:
112
+ elapsed = time.monotonic() - t0
113
+ print(
114
+ f"{name}: FAILED ({type(exc).__name__}) in {elapsed:.2f}s, "
115
+ f"{resolver.stats.decisions} decisions"
116
+ )
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -0,0 +1 @@
1
+ *
@@ -0,0 +1,459 @@
1
+ """Quick canary benchmark for fast iteration.
2
+
3
+ Runs a curated subset of scenarios (canaries + hard cases) N times each
4
+ and reports median decisions, conflicts, and wall time. The set is
5
+ small enough to finish in a few minutes so it can be re-run after each
6
+ algorithm change.
7
+
8
+ Usage:
9
+ python nab-python/benchmarks/canary.py [--commit LABEL] [--runs N]
10
+ [--scenario NAME] [--scenarios FILE]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import signal
18
+ import statistics
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Mapping
28
+
29
+ if sys.version_info >= (3, 11):
30
+ import tomllib
31
+ else:
32
+ import tomli as tomllib # type: ignore[no-redef]
33
+
34
+ from nab_index.httpx_async_transport import HttpxAsyncTransport
35
+ from nab_index.multi_index import IndexConfig
36
+ from nab_python._vcs_admission import admit_vcs_url
37
+ from nab_python._vendor.packaging.markers import default_environment
38
+ from nab_python._vendor.packaging.ranges import VersionRange
39
+ from nab_python._vendor.packaging.requirements import Requirement
40
+ from nab_python._vendor.packaging.utils import canonicalize_name
41
+ from nab_python.fetch import (
42
+ DEFAULT_INDEX_NAME,
43
+ DEFAULT_INDEX_URL,
44
+ FetchCoordinator,
45
+ IndexOverride,
46
+ )
47
+ from nab_python.provider import (
48
+ BuildPolicy,
49
+ DistPolicy,
50
+ Provider,
51
+ VcsConfig,
52
+ VcsPolicy,
53
+ split_extra,
54
+ )
55
+ from nab_resolver.resolver import Resolver
56
+
57
+ BENCHMARKS_DIR = Path(__file__).parent
58
+ SCENARIOS_DIR = BENCHMARKS_DIR / "scenarios"
59
+ RESULTS_DIR = BENCHMARKS_DIR / "canary_results"
60
+ CACHE_DIR = BENCHMARKS_DIR / "cache"
61
+ DEFAULT_INDEXES: tuple[IndexConfig, ...] = (
62
+ IndexConfig(DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL),
63
+ )
64
+
65
+ CANARY_SCENARIOS = [
66
+ "boto3-urllib3-transient",
67
+ "trustllm",
68
+ "copick",
69
+ "promptflow-vectordb",
70
+ "ultralytics-export",
71
+ "datacontract-cli",
72
+ "pandas-aws-boto3-dandi-frenzy",
73
+ "vllm-transformers-floor",
74
+ "google-bigquery-soda",
75
+ "langchain-ml-course",
76
+ "airflow-3-0-2-awswrangler",
77
+ "airflow-3-0-3-pandas-sqlalchemy",
78
+ "airflow-portalocker-qdrant",
79
+ "airflow-fastapi-121",
80
+ "so-dbt-core-snowflake-79744735",
81
+ "uv-issue-16601-xinference",
82
+ "uv-issue-16601-xinference-fixed",
83
+ "rag-chroma-langchain",
84
+ "streamlit-langchain",
85
+ ]
86
+
87
+ WALL_TIMEOUT_S = 60
88
+ MAX_ITERATIONS = 50_000
89
+
90
+
91
+ class _ScenarioTimeoutError(Exception):
92
+ pass
93
+
94
+
95
+ def _alarm_handler(_signum: int, _frame: object) -> None:
96
+ msg = f"scenario exceeded {WALL_TIMEOUT_S}s wall-clock budget"
97
+ raise _ScenarioTimeoutError(msg)
98
+
99
+
100
+ def expand_project_extras(
101
+ project_name: str,
102
+ requested_extras: list[str],
103
+ optional_dependencies: dict[str, list[str]],
104
+ ) -> list[str]:
105
+ canonical_project = canonicalize_name(project_name)
106
+ norm_optional = {canonicalize_name(k): v for k, v in optional_dependencies.items()}
107
+ visited: set[str] = set()
108
+ out: list[str] = []
109
+
110
+ def visit(extra: str) -> None:
111
+ norm = canonicalize_name(extra)
112
+ if norm in visited:
113
+ return
114
+ visited.add(norm)
115
+ for dep_str in norm_optional.get(norm, []):
116
+ req = Requirement(dep_str)
117
+ if canonicalize_name(req.name) == canonical_project:
118
+ for sub_extra in sorted(req.extras):
119
+ visit(sub_extra)
120
+ else:
121
+ out.append(dep_str)
122
+
123
+ for extra in requested_extras:
124
+ visit(extra)
125
+ return out
126
+
127
+
128
+ def parse_requirements(
129
+ requirement_strings: list[str],
130
+ *,
131
+ vcs_config: VcsConfig | None = None,
132
+ marker_environment: dict[str, str] | None = None,
133
+ ) -> dict[str, VersionRange]:
134
+ config = vcs_config or VcsConfig()
135
+ env = _full_marker_environment(marker_environment)
136
+ reqs: dict[str, VersionRange] = {}
137
+ for req_str in requirement_strings:
138
+ req = Requirement(req_str)
139
+ if req.marker is not None and not req.marker.evaluate(env):
140
+ continue
141
+ if req.url is not None:
142
+ admit_vcs_url(req.url, config)
143
+ msg = (
144
+ f"VCS requirement admitted by policy but resolver path is not"
145
+ f" yet implemented: {req.name} @ {req.url}"
146
+ )
147
+ raise NotImplementedError(msg)
148
+ name = canonicalize_name(req.name)
149
+ reqs[name] = req.specifier.to_range()
150
+ for extra in req.extras:
151
+ reqs[f"{name}[{extra}]"] = VersionRange.full()
152
+ return reqs
153
+
154
+
155
+ def _full_marker_environment(
156
+ overlay: dict[str, str] | None,
157
+ ) -> dict[str, str]:
158
+ env = dict(default_environment())
159
+ if overlay:
160
+ env.update(overlay)
161
+ return env
162
+
163
+
164
+ _PYTHON_VERSION_PARTS = 2
165
+
166
+
167
+ def _scenario_marker_env(
168
+ python_version: str,
169
+ overlay: dict[str, str],
170
+ ) -> dict[str, str]:
171
+ env = dict(overlay)
172
+ env.setdefault("python_full_version", python_version)
173
+ if "python_version" not in env:
174
+ parts = python_version.split(".")
175
+ env["python_version"] = (
176
+ ".".join(parts[:_PYTHON_VERSION_PARTS])
177
+ if len(parts) >= _PYTHON_VERSION_PARTS
178
+ else python_version
179
+ )
180
+ return env
181
+
182
+
183
+ def parse_datetime(value: str) -> datetime:
184
+ dt = datetime.fromisoformat(value)
185
+ if dt.tzinfo is None:
186
+ dt = dt.replace(tzinfo=timezone.utc)
187
+ return dt
188
+
189
+
190
+ def get_git_commit() -> str:
191
+ result = subprocess.run(
192
+ ["git", "rev-parse", "--short", "HEAD"],
193
+ capture_output=True,
194
+ text=True,
195
+ check=True,
196
+ )
197
+ return result.stdout.strip()
198
+
199
+
200
+ def run_one(
201
+ requirements: dict[str, VersionRange],
202
+ python_version: str,
203
+ uploaded_prior_to: datetime | None,
204
+ constraints: dict[str, VersionRange] | None,
205
+ marker_environment: dict[str, str] | None = None,
206
+ indexes: list[IndexConfig] | None = None,
207
+ index_overrides: list[IndexOverride] | None = None,
208
+ build_policy_overrides: Mapping[str, BuildPolicy] | None = None,
209
+ ) -> dict:
210
+ with FetchCoordinator(
211
+ HttpxAsyncTransport(),
212
+ indexes=indexes,
213
+ cache_dir=CACHE_DIR,
214
+ index_overrides=index_overrides,
215
+ marker_environment=marker_environment,
216
+ ) as coordinator:
217
+ provider = Provider(
218
+ coordinator,
219
+ python_version=python_version,
220
+ root_requirements=requirements,
221
+ uploaded_prior_to=uploaded_prior_to,
222
+ dist_policy=DistPolicy.WHEEL_OR_SDIST,
223
+ build_policy=BuildPolicy.NEVER,
224
+ build_policy_overrides=build_policy_overrides,
225
+ marker_environment=marker_environment,
226
+ )
227
+ resolver = Resolver(
228
+ provider,
229
+ range_type=VersionRange,
230
+ root_version="0",
231
+ max_iterations=MAX_ITERATIONS,
232
+ )
233
+
234
+ previous_handler = signal.signal(signal.SIGALRM, _alarm_handler)
235
+ signal.alarm(WALL_TIMEOUT_S)
236
+ start = time.monotonic()
237
+ try:
238
+ raw = resolver.resolve(requirements, constraints=constraints)
239
+ elapsed = time.monotonic() - start
240
+ result = {k: v for k, v in raw.items() if split_extra(k)[1] is None}
241
+ success = True
242
+ error = None
243
+ packages = len(result)
244
+ except Exception as exc:
245
+ elapsed = time.monotonic() - start
246
+ success = False
247
+ error = f"{type(exc).__name__}: {exc}"
248
+ packages = 0
249
+ finally:
250
+ signal.alarm(0)
251
+ signal.signal(signal.SIGALRM, previous_handler)
252
+
253
+ rs = resolver.stats
254
+ ps = provider.stats
255
+ return {
256
+ "success": success,
257
+ "error": error,
258
+ "decisions": rs.decisions,
259
+ "conflicts": rs.conflicts,
260
+ "backjumps": rs.backjumps,
261
+ "restarts": rs.restarts,
262
+ "incompatibilities_learned": rs.incompatibilities_learned,
263
+ "metadata_fetched": ps.metadata_fetched,
264
+ "look_ahead_rejections": ps.look_ahead_rejections,
265
+ "packages": packages,
266
+ "wall_time_seconds": round(elapsed, 3),
267
+ }
268
+
269
+
270
+ def find_scenario(scenario_name: str) -> dict | None:
271
+ for toml_file in SCENARIOS_DIR.glob("*.toml"):
272
+ with toml_file.open("rb") as f:
273
+ data = tomllib.load(f)
274
+ if scenario_name in data:
275
+ return data[scenario_name]
276
+ return None
277
+
278
+
279
+ def median_run(scenario: dict, runs: int) -> tuple[list[dict], dict]:
280
+ if "unsupported_reason" in scenario:
281
+ return [], {"skipped": scenario["unsupported_reason"]}
282
+
283
+ python_version = scenario["python_version"]
284
+ requirement_strings = scenario["requirements"]
285
+ constraint_strings = scenario.get("constraints", [])
286
+ platform_system = scenario.get("platform_system")
287
+ marker_environment_raw = scenario.get("marker_environment", {})
288
+ if not isinstance(marker_environment_raw, dict):
289
+ msg = (
290
+ "marker_environment must be a TOML table of string -> string,"
291
+ f" got {type(marker_environment_raw).__name__}"
292
+ )
293
+ raise TypeError(msg)
294
+ marker_environment: dict[str, str] = {
295
+ str(k): str(v) for k, v in marker_environment_raw.items()
296
+ }
297
+ if platform_system and "platform_system" not in marker_environment:
298
+ marker_environment["platform_system"] = platform_system
299
+ datetime_str = scenario.get("datetime")
300
+ project_name = scenario.get("project_name")
301
+ project_extras = scenario.get("project_extras", [])
302
+ optional_dependencies = scenario.get("optional_dependencies", {})
303
+ vcs_config = VcsConfig(
304
+ policy=VcsPolicy(scenario.get("vcs_policy", "block")),
305
+ allowed_schemes=frozenset(scenario.get("vcs_allowed_schemes", [])),
306
+ allowed_repos=tuple(scenario.get("vcs_allowed_repos", [])),
307
+ require_pin=scenario.get("vcs_require_pin", True),
308
+ )
309
+
310
+ if project_name:
311
+ requirement_strings = [
312
+ *requirement_strings,
313
+ *expand_project_extras(project_name, project_extras, optional_dependencies),
314
+ ]
315
+
316
+ raw_indexes = scenario.get("indexes")
317
+ if raw_indexes is None:
318
+ indexes = list(DEFAULT_INDEXES)
319
+ else:
320
+ indexes = [
321
+ IndexConfig(name=str(entry["name"]), url=str(entry["url"]))
322
+ for entry in raw_indexes
323
+ ]
324
+ raw_overrides = scenario.get("index_overrides", [])
325
+ index_overrides = [
326
+ IndexOverride(
327
+ name=str(entry["name"]),
328
+ index=str(entry["index"]),
329
+ marker=entry.get("marker"),
330
+ )
331
+ for entry in raw_overrides
332
+ ]
333
+ raw_build_packages = scenario.get("build_packages", []) or []
334
+ build_policy_overrides = {
335
+ str(name): BuildPolicy.BUILD_REMOTE for name in raw_build_packages
336
+ }
337
+ if marker_environment and build_policy_overrides:
338
+ # See ``scenarios.py``: marker_environment + BUILD_REMOTE override
339
+ # is rejected to preserve metadata soundness. Drop the overrides;
340
+ # a resolution that now fails was relying on the previous silent
341
+ # passthrough and needs an audit.
342
+ print(
343
+ f" [audit] dropping {len(build_policy_overrides)} build_packages"
344
+ " override(s) because of marker_environment overlay.",
345
+ flush=True,
346
+ )
347
+ build_policy_overrides = {}
348
+ requirement_marker_env = _scenario_marker_env(python_version, marker_environment)
349
+ requirements = parse_requirements(
350
+ requirement_strings,
351
+ vcs_config=vcs_config,
352
+ marker_environment=requirement_marker_env,
353
+ )
354
+ constraints = (
355
+ parse_requirements(
356
+ constraint_strings,
357
+ vcs_config=vcs_config,
358
+ marker_environment=requirement_marker_env,
359
+ )
360
+ if constraint_strings
361
+ else None
362
+ )
363
+ uploaded_prior_to = parse_datetime(datetime_str) if datetime_str else None
364
+
365
+ runs_data: list[dict] = [
366
+ run_one(
367
+ requirements,
368
+ python_version,
369
+ uploaded_prior_to,
370
+ constraints,
371
+ marker_environment=marker_environment or None,
372
+ indexes=indexes,
373
+ index_overrides=index_overrides or None,
374
+ build_policy_overrides=build_policy_overrides or None,
375
+ )
376
+ for _ in range(runs)
377
+ ]
378
+
379
+ def med(key: str) -> float:
380
+ vals = [r[key] for r in runs_data if isinstance(r.get(key), (int, float))]
381
+ return statistics.median(vals) if vals else 0
382
+
383
+ successes = sum(1 for r in runs_data if r["success"])
384
+ summary = {
385
+ "success_runs": f"{successes}/{len(runs_data)}",
386
+ "median_decisions": int(med("decisions")),
387
+ "median_conflicts": int(med("conflicts")),
388
+ "median_backjumps": int(med("backjumps")),
389
+ "median_wall": round(med("wall_time_seconds"), 2),
390
+ "min_decisions": min(r["decisions"] for r in runs_data),
391
+ "max_decisions": max(r["decisions"] for r in runs_data),
392
+ "min_wall": round(min(r["wall_time_seconds"] for r in runs_data), 2),
393
+ "max_wall": round(max(r["wall_time_seconds"] for r in runs_data), 2),
394
+ }
395
+ return runs_data, summary
396
+
397
+
398
+ def main() -> None:
399
+ parser = argparse.ArgumentParser(description="Run canary benchmark")
400
+ parser.add_argument("--commit", default=None)
401
+ parser.add_argument("--runs", type=int, default=3)
402
+ parser.add_argument("--scenario", action="append", help="Run only named scenarios")
403
+ parser.add_argument("--scenarios-list", help="File with one scenario per line")
404
+ args = parser.parse_args()
405
+
406
+ commit = args.commit or get_git_commit()
407
+
408
+ scenarios_to_run: list[str]
409
+ if args.scenarios_list:
410
+ with Path(args.scenarios_list).open(encoding="utf-8") as f:
411
+ scenarios_to_run = [line.strip() for line in f if line.strip()]
412
+ elif args.scenario:
413
+ scenarios_to_run = args.scenario
414
+ else:
415
+ scenarios_to_run = list(CANARY_SCENARIOS)
416
+
417
+ out_dir = RESULTS_DIR / commit
418
+ out_dir.mkdir(parents=True, exist_ok=True)
419
+
420
+ print(f"\n=== Canary benchmark, commit={commit}, runs={args.runs} ===")
421
+ print(
422
+ f"{'scenario':<45} "
423
+ f"{'success':>9} "
424
+ f"{'med_dec':>8} "
425
+ f"{'med_wall':>10} "
426
+ f"{'min_dec':>8} "
427
+ f"{'max_dec':>8}"
428
+ )
429
+ print("-" * 100)
430
+
431
+ summary_all: dict[str, dict] = {}
432
+ for name in scenarios_to_run:
433
+ scenario = find_scenario(name)
434
+ if scenario is None:
435
+ print(f"{name:<45} NOT FOUND")
436
+ continue
437
+ runs_data, summary = median_run(scenario, args.runs)
438
+ summary_all[name] = {"runs": runs_data, "summary": summary}
439
+
440
+ if "skipped" in summary:
441
+ print(f"{name:<45} SKIPPED: {summary['skipped']}")
442
+ continue
443
+
444
+ print(
445
+ f"{name:<45} "
446
+ f"{summary['success_runs']:>9} "
447
+ f"{summary['median_decisions']:>8} "
448
+ f"{summary['median_wall']:>10} "
449
+ f"{summary['min_decisions']:>8} "
450
+ f"{summary['max_decisions']:>8}"
451
+ )
452
+
453
+ out_file = out_dir / f"canary_{int(time.time())}.json"
454
+ out_file.write_text(json.dumps(summary_all, indent=2) + "\n")
455
+ print(f"\nResults: {out_file}")
456
+
457
+
458
+ if __name__ == "__main__":
459
+ main()
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore