fc-data 0.2.0__py3-none-any.whl

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 (87) hide show
  1. datasmith/__init__.py +330 -0
  2. datasmith/__init__.pyi +194 -0
  3. datasmith/agents/__init__.py +31 -0
  4. datasmith/agents/classifiers.py +272 -0
  5. datasmith/agents/codex.py +25 -0
  6. datasmith/agents/config.py +108 -0
  7. datasmith/agents/extractors.py +197 -0
  8. datasmith/agents/installed/README.md +52 -0
  9. datasmith/agents/installed/__init__.py +22 -0
  10. datasmith/agents/installed/base.py +240 -0
  11. datasmith/agents/installed/claude.py +134 -0
  12. datasmith/agents/installed/codex.py +91 -0
  13. datasmith/agents/installed/gemini.py +118 -0
  14. datasmith/agents/installed/none.py +27 -0
  15. datasmith/agents/sandbox.py +547 -0
  16. datasmith/agents/synthesizer.py +439 -0
  17. datasmith/agents/templates/AGENTS.md.j2 +150 -0
  18. datasmith/agents/templates/sandbox_verify.py +428 -0
  19. datasmith/docker/__init__.py +31 -0
  20. datasmith/docker/context.py +112 -0
  21. datasmith/docker/images.py +158 -0
  22. datasmith/docker/publish.py +56 -0
  23. datasmith/docker/templates/Dockerfile.base +26 -0
  24. datasmith/docker/templates/Dockerfile.pr +42 -0
  25. datasmith/docker/templates/Dockerfile.repo +11 -0
  26. datasmith/docker/templates/docker_build_base.sh +780 -0
  27. datasmith/docker/templates/docker_build_env.sh +309 -0
  28. datasmith/docker/templates/docker_build_final.sh +106 -0
  29. datasmith/docker/templates/docker_build_pkg.sh +99 -0
  30. datasmith/docker/templates/docker_build_run.sh +124 -0
  31. datasmith/docker/templates/entrypoint.sh +62 -0
  32. datasmith/docker/templates/parser.py +1405 -0
  33. datasmith/docker/templates/profile.sh +199 -0
  34. datasmith/docker/templates/pytest_runner.py +692 -0
  35. datasmith/docker/templates/run-tests.sh +197 -0
  36. datasmith/docker/verifiers.py +131 -0
  37. datasmith/filters.py +154 -0
  38. datasmith/github/__init__.py +22 -0
  39. datasmith/github/client.py +333 -0
  40. datasmith/github/hooks.py +50 -0
  41. datasmith/github/links.py +110 -0
  42. datasmith/github/models.py +206 -0
  43. datasmith/github/render.py +173 -0
  44. datasmith/github/search.py +66 -0
  45. datasmith/github/templates/comment.md.j2 +5 -0
  46. datasmith/github/templates/final.md.j2 +66 -0
  47. datasmith/github/templates/issues.md.j2 +21 -0
  48. datasmith/github/templates/repo.md.j2 +1 -0
  49. datasmith/preflight.py +162 -0
  50. datasmith/publish/__init__.py +13 -0
  51. datasmith/publish/huggingface.py +104 -0
  52. datasmith/publish/pipeline.py +60 -0
  53. datasmith/publish/records.py +91 -0
  54. datasmith/py.typed +1 -0
  55. datasmith/resolution/__init__.py +14 -0
  56. datasmith/resolution/blocklist.py +145 -0
  57. datasmith/resolution/cache.py +120 -0
  58. datasmith/resolution/constants.py +277 -0
  59. datasmith/resolution/dependency_resolver.py +174 -0
  60. datasmith/resolution/git_utils.py +378 -0
  61. datasmith/resolution/import_analyzer.py +66 -0
  62. datasmith/resolution/metadata_parser.py +412 -0
  63. datasmith/resolution/models.py +41 -0
  64. datasmith/resolution/orchestrator.py +522 -0
  65. datasmith/resolution/package_filters.py +312 -0
  66. datasmith/resolution/python_manager.py +110 -0
  67. datasmith/runners/__init__.py +15 -0
  68. datasmith/runners/base.py +112 -0
  69. datasmith/runners/classify_prs.py +48 -0
  70. datasmith/runners/render_problems.py +113 -0
  71. datasmith/runners/resolve_packages.py +66 -0
  72. datasmith/runners/scrape_commits.py +166 -0
  73. datasmith/runners/scrape_repos.py +44 -0
  74. datasmith/runners/synthesize_images.py +310 -0
  75. datasmith/update/__init__.py +5 -0
  76. datasmith/update/cli.py +169 -0
  77. datasmith/update/offline.py +173 -0
  78. datasmith/update/pipeline.py +497 -0
  79. datasmith/utils/__init__.py +18 -0
  80. datasmith/utils/core.py +67 -0
  81. datasmith/utils/db.py +156 -0
  82. datasmith/utils/tokens.py +65 -0
  83. fc_data-0.2.0.dist-info/METADATA +441 -0
  84. fc_data-0.2.0.dist-info/RECORD +87 -0
  85. fc_data-0.2.0.dist-info/WHEEL +4 -0
  86. fc_data-0.2.0.dist-info/entry_points.txt +2 -0
  87. fc_data-0.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,522 @@
1
+ """Main orchestration for commit analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import json5
14
+
15
+ from datasmith.utils import get_logger
16
+
17
+ from .cache import cache_completion
18
+ from .constants import ALLOWLIST_COMMON_PYPI, CACHE_LOCATION
19
+ from .dependency_resolver import (
20
+ rfc3339,
21
+ uv_build_and_read_metadata,
22
+ uv_compile,
23
+ uv_compile_from_pyproject,
24
+ uv_dry_run_install,
25
+ uv_install_real,
26
+ )
27
+ from .git_utils import asv_finder, prepare_repo_checkout
28
+ from .import_analyzer import infer_runtime_from_imports
29
+ from .metadata_parser import analyze_candidate_meta, discover_candidates, select_primary_candidate
30
+ from .models import ASVCfgAggregate
31
+ from .package_filters import (
32
+ clean_pinned,
33
+ extract_pkg_name,
34
+ extract_requested_extras,
35
+ filter_requirements_for_pypi,
36
+ normalize_requirement,
37
+ resolve_requirements_file,
38
+ split_shell_command,
39
+ )
40
+ from .python_manager import ensure_python_version_available, filter_python_versions_by_commit_date, run_uv
41
+
42
+ logger = get_logger("resolution.orchestrator")
43
+
44
+
45
+ @cache_completion(CACHE_LOCATION, table_name="commit_analysis")
46
+ def analyze_commit(sha: str, repo_name: str, bypass_cache: bool = False) -> dict[str, Any] | None: # noqa: C901
47
+ """Analyze a commit to extract build/runtime information for benchmarking.
48
+
49
+ Returns a dictionary with resolution results, or None if analysis failed.
50
+ """
51
+ commit_info: dict[str, Any] | None = None
52
+
53
+ python_version: str | None = None
54
+ resolved_dependencies: list[str] = []
55
+ resolution_strategy: str | None = None
56
+ can_install: bool = False
57
+ dry_run_log: str = ""
58
+ excluded_missing_on_pypi: dict[str, str] = {}
59
+ excluded_exists_incompatible: dict[str, str] = {}
60
+ excluded_other: dict[str, str] = {}
61
+
62
+ with tempfile.TemporaryDirectory() as tmpdir:
63
+ tmp_path = Path(tmpdir)
64
+ repo, tmpfile_pth, _cleanup_checkout = prepare_repo_checkout(repo_name, sha, tmp_path)
65
+ try:
66
+ commit = repo.commit(sha)
67
+ with contextlib.suppress(Exception):
68
+ repo.git.checkout(sha)
69
+
70
+ # A) ASV configs (optional — repos without ASV can still be resolved)
71
+ asv_cfg_files = asv_finder(commit)
72
+
73
+ asv_cfgs = []
74
+ for cfg_file in asv_cfg_files:
75
+ with contextlib.suppress(Exception):
76
+ asv_cfgs.append(json5.loads(cfg_file.read_text()))
77
+
78
+ cfg_items = ASVCfgAggregate()
79
+ for cfg in asv_cfgs:
80
+ pythons: set[tuple[int, ...]] = set()
81
+ for py in getattr(cfg, "pythons", []) or []:
82
+ with contextlib.suppress(Exception):
83
+ pythons.add(tuple(map(int, str(py).split("."))))
84
+ cfg_items.pythons.update(pythons)
85
+ bc = getattr(cfg, "build_command", None)
86
+ ic = getattr(cfg, "install_command", None)
87
+ if bc:
88
+ if isinstance(bc, (list, tuple)):
89
+ bc = " && ".join(bc).replace("-mpip", "-m pip")
90
+ cfg_items.build_commands.add(str(bc))
91
+ if ic:
92
+ if isinstance(ic, (list, tuple)):
93
+ ic = " && ".join(ic)
94
+ cfg_items.install_commands.add(str(ic))
95
+ mx = getattr(cfg, "matrix", None) or {}
96
+ for k, v in mx.items():
97
+ values = cfg_items.matrix.setdefault(k, set())
98
+ if isinstance(v, (list, tuple, set)):
99
+ values.update(map(str, v))
100
+ else:
101
+ values.add(str(v))
102
+
103
+ if not cfg_items.pythons:
104
+ cfg_items.pythons.update({(3, 8), (3, 9), (3, 10), (3, 11), (3, 12)})
105
+
106
+ # B) Choose Python version candidates
107
+ if (not cfg_items.pythons) or all(py < (3, 8) for py in cfg_items.pythons):
108
+ logger.debug("No Python >=3.8 available in ASV config: %s", cfg_items.pythons)
109
+ return None
110
+
111
+ authored = commit.authored_datetime
112
+ cutoff = rfc3339(authored)
113
+ candidate_python_versions = filter_python_versions_by_commit_date(cfg_items.pythons, authored)
114
+
115
+ if not candidate_python_versions:
116
+ logger.debug("No suitable Python versions after temporal filtering from %s", cfg_items.pythons)
117
+ return None
118
+
119
+ # C) Discover packaging candidates
120
+ candidates = discover_candidates(commit)
121
+ if not candidates:
122
+ return None
123
+ analyzed: dict[str, Any] = {root: analyze_candidate_meta(c) for root, c in candidates.items()}
124
+ primary_root = select_primary_candidate(repo_name, candidates, cfg_items.install_commands, analyzed)
125
+ primary_meta = analyzed[primary_root]
126
+ primary_cand = candidates[primary_root]
127
+ project_dir = tmpfile_pth / primary_root
128
+ all_sources = [
129
+ s
130
+ for s in (primary_cand.setup_py_path, primary_cand.pyproject_path, primary_cand.setup_cfg_path)
131
+ if s and s.exists()
132
+ ]
133
+ if len(all_sources):
134
+ for source in all_sources:
135
+ skip_source = False
136
+ for py_ver in (".".join(map(str, t)) for t in candidate_python_versions[:3]):
137
+ if skip_source:
138
+ break
139
+ for strict_cutoff in (True, False):
140
+ source_name = source.name.replace(".", "_")
141
+ candidate_venv_path = Path(tmpdir) / f"venv_{source_name}_{py_ver.replace('.', '_')}"
142
+ try:
143
+ venv_cp = run_uv(["venv", str(candidate_venv_path), "--python", py_ver])
144
+ if venv_cp.returncode != 0:
145
+ logger.debug(
146
+ "Failed to create venv with Python %s: %s", py_ver, venv_cp.stderr.decode()
147
+ )
148
+ continue
149
+
150
+ python_exe = candidate_venv_path / "bin" / "python"
151
+ if not python_exe.exists():
152
+ python_exe = candidate_venv_path / "Scripts" / "python.exe"
153
+
154
+ if not python_exe.exists():
155
+ logger.debug("Venv created but Python executable not found for version %s", py_ver)
156
+ shutil.rmtree(candidate_venv_path, ignore_errors=True)
157
+ continue
158
+
159
+ resolved = uv_compile_from_pyproject(
160
+ project_dir / source.name,
161
+ python_version=python_exe.as_posix(),
162
+ cutoff_rfc3339=cutoff if strict_cutoff else None,
163
+ )
164
+ except Exception as e:
165
+ shutil.rmtree(candidate_venv_path, ignore_errors=True)
166
+ logger.debug(
167
+ "uv_compile_from_pyproject failed for Python %s with cutoff %s: %s",
168
+ py_ver,
169
+ "strict" if strict_cutoff else "none",
170
+ e,
171
+ )
172
+ if "--no-build-isolation" in str(e):
173
+ skip_source = True
174
+ break
175
+ continue
176
+ strat = f"{'cutoff=strict' if strict_cutoff else 'cutoff=none'}, extras=on, python={py_ver}, source={source.name}"
177
+
178
+ if len(resolved) > 0:
179
+ candidate_can_install, candidate_dry_run_log = uv_dry_run_install(
180
+ resolved, python_version=py_ver, venv_path=candidate_venv_path
181
+ )
182
+
183
+ if candidate_can_install:
184
+ ok_real, real_log = uv_install_real(
185
+ resolved, python_executable=python_exe.as_posix()
186
+ )
187
+ if ok_real:
188
+ shutil.rmtree(candidate_venv_path, ignore_errors=True)
189
+ commit_info = {
190
+ "sha": sha,
191
+ "repo_name": repo_name,
192
+ "package_name": primary_meta.name,
193
+ "package_version": primary_meta.version,
194
+ "python_version": py_ver,
195
+ "build_command": list(cfg_items.build_commands),
196
+ "install_command": list(cfg_items.install_commands),
197
+ "final_dependencies": list(dict.fromkeys(resolved)),
198
+ "can_install": True,
199
+ "dry_run_log": candidate_dry_run_log,
200
+ "primary_root": primary_root,
201
+ "resolution_strategy": strat,
202
+ "excluded_missing_on_pypi": {},
203
+ "excluded_exists_incompatible": {},
204
+ "excluded_other": {},
205
+ }
206
+ return commit_info
207
+ else:
208
+ logger.debug(
209
+ "Preflight install failed for Python %s (source=%s); trying next.\n%s",
210
+ py_ver,
211
+ source.name,
212
+ real_log[-800:],
213
+ )
214
+ shutil.rmtree(candidate_venv_path, ignore_errors=True)
215
+ else:
216
+ shutil.rmtree(candidate_venv_path, ignore_errors=True)
217
+
218
+ # D) Aggregate base requirements (unresolved, human-intent)
219
+ base_requirements: set[str] = set()
220
+ base_requirements.update(primary_meta.core_deps)
221
+ base_requirements.add("pytest")
222
+ base_requirements.add("setuptools")
223
+ base_requirements.add("hypothesis")
224
+
225
+ requested_extras = extract_requested_extras(
226
+ cfg_items.install_commands, cfg_items.matrix, primary_meta.extras.keys()
227
+ )
228
+ for ex in requested_extras:
229
+ base_requirements.update(primary_meta.extras.get(ex, set()))
230
+
231
+ for install_cmd in cfg_items.install_commands:
232
+ for cmd_part in split_shell_command(install_cmd):
233
+ try:
234
+ tokens = shlex.split(cmd_part)
235
+ except Exception:
236
+ logger.exception("Failed to split command %s", cmd_part)
237
+ continue
238
+
239
+ skip_next = False
240
+ for i, tok in enumerate(tokens):
241
+ if skip_next:
242
+ skip_next = False
243
+ continue
244
+ if tok in {"-r", "--requirement"} and i + 1 < len(tokens):
245
+ rel = tokens[i + 1]
246
+ skip_next = True
247
+ requirements_from_file = resolve_requirements_file(commit, rel, set())
248
+ base_requirements.update(requirements_from_file)
249
+ continue
250
+
251
+ skip_next = False
252
+ for tok in tokens:
253
+ if skip_next:
254
+ skip_next = False
255
+ continue
256
+ if tok in {"-r", "--requirement"}:
257
+ skip_next = True
258
+ continue
259
+ if tok.startswith("-"):
260
+ continue
261
+ normalized = normalize_requirement(tok)
262
+ base_requirements.update(normalized)
263
+
264
+ for vals in cfg_items.matrix.values():
265
+ for v in vals:
266
+ s = str(v).strip()
267
+ if s and not s.startswith("-"):
268
+ normalized = normalize_requirement(s)
269
+ base_requirements.update(normalized)
270
+
271
+ # E) Build and read wheel metadata
272
+ project_dir = tmpfile_pth / primary_root
273
+ pkg_name, pkg_version, wheel_requires, wheel_requires_python = uv_build_and_read_metadata(project_dir)
274
+
275
+ if not primary_meta.name and pkg_name:
276
+ primary_meta.name = pkg_name
277
+ if not primary_meta.version and pkg_version:
278
+ primary_meta.version = pkg_version
279
+ if wheel_requires_python and not primary_meta.requires_python:
280
+ primary_meta.requires_python = wheel_requires_python
281
+
282
+ own_import = None
283
+ if primary_meta.name:
284
+ own_import = primary_meta.name.replace("-", "_")
285
+
286
+ # F) Candidate runtime requirements (unresolved)
287
+ runtime_candidates: set[str] = set(wheel_requires)
288
+ if not runtime_candidates:
289
+ runtime_inferred = infer_runtime_from_imports(project_dir, own_import_name=own_import)
290
+ build_names = {re.split(r"[~<>=!; ]", breq, maxsplit=1)[0] for breq in primary_meta.build_requires}
291
+ promote = {x for x in runtime_inferred if x in build_names}
292
+ runtime_candidates.update(runtime_inferred)
293
+ runtime_candidates.update(promote)
294
+
295
+ runtime_candidates.update(base_requirements)
296
+
297
+ cleaned_unresolved = filter_requirements_for_pypi(
298
+ runtime_candidates,
299
+ project_dir=project_dir,
300
+ own_import_name=own_import,
301
+ )
302
+
303
+ if not cleaned_unresolved and runtime_candidates:
304
+ cleaned_unresolved = sorted({
305
+ r for r in runtime_candidates if extract_pkg_name(r) in ALLOWLIST_COMMON_PYPI
306
+ })
307
+
308
+ found_flag = False
309
+
310
+ for use_cleaned_pinned in (False, True):
311
+ for py_tuple in candidate_python_versions:
312
+ if found_flag:
313
+ break
314
+ candidate_version = ".".join(map(str, py_tuple))
315
+ logger.debug("Trying Python %s", candidate_version)
316
+
317
+ if not ensure_python_version_available(candidate_version):
318
+ logger.debug("Python %s not available, trying next", candidate_version)
319
+ continue
320
+
321
+ candidate_venv_path = Path(tmpdir) / f"venv_{candidate_version.replace('.', '_')}"
322
+ venv_cp = run_uv(["venv", str(candidate_venv_path), "--python", candidate_version])
323
+
324
+ if venv_cp.returncode != 0:
325
+ logger.debug(
326
+ "Failed to create venv with Python %s: %s", candidate_version, venv_cp.stderr.decode()
327
+ )
328
+ continue
329
+
330
+ python_exe = candidate_venv_path / "bin" / "python"
331
+ if not python_exe.exists():
332
+ python_exe = candidate_venv_path / "Scripts" / "python.exe"
333
+
334
+ if not python_exe.exists():
335
+ logger.debug("Venv created but Python executable not found for version %s", candidate_version)
336
+ continue
337
+
338
+ def _compile_or_pass_through(
339
+ reqs: list[str], *, strict_cutoff: bool, py_ver: str
340
+ ) -> tuple[list[str], str]:
341
+ from .blocklist import (
342
+ add_to_blocklist,
343
+ extract_failing_package,
344
+ remove_package_from_requirements,
345
+ )
346
+
347
+ current_reqs = list(reqs)
348
+ max_compile_retries = 3
349
+ compile_retry_count = 0
350
+
351
+ while compile_retry_count <= max_compile_retries:
352
+ try:
353
+ resolved = uv_compile(
354
+ current_reqs,
355
+ python_version=py_ver,
356
+ cutoff_rfc3339=cutoff if strict_cutoff else None,
357
+ )
358
+ strat = (
359
+ f"{'cutoff=strict' if strict_cutoff else 'cutoff=none'}, extras=on, python={py_ver}"
360
+ )
361
+ if compile_retry_count > 0:
362
+ strat = f"{strat} (compile-healed: {compile_retry_count} pkgs)"
363
+ return resolved, strat
364
+ except Exception as e:
365
+ error_msg = str(e)
366
+
367
+ if compile_retry_count < max_compile_retries and (
368
+ "was not found in the package registry" in error_msg
369
+ or "Because there are no versions of" in error_msg
370
+ ):
371
+ failing_pkg = extract_failing_package(error_msg)
372
+ if failing_pkg:
373
+ if add_to_blocklist(failing_pkg):
374
+ logger.info(
375
+ "Compile self-healing: Blocking '%s' (retry %d/%d)",
376
+ failing_pkg,
377
+ compile_retry_count + 1,
378
+ max_compile_retries,
379
+ )
380
+ current_reqs, was_removed = remove_package_from_requirements(
381
+ current_reqs, failing_pkg
382
+ )
383
+ if was_removed:
384
+ compile_retry_count += 1
385
+ continue
386
+
387
+ return list(current_reqs), f"unresolved(pass-through): {e.__class__.__name__}"
388
+
389
+ return list(current_reqs), "unresolved(max-retries-exceeded)"
390
+
391
+ if use_cleaned_pinned:
392
+ cleaned_unresolved = clean_pinned(cleaned_unresolved)
393
+ candidate_resolved, candidate_strategy = _compile_or_pass_through(
394
+ cleaned_unresolved, strict_cutoff=True, py_ver=candidate_version
395
+ )
396
+
397
+ if candidate_resolved == cleaned_unresolved and candidate_resolved:
398
+ relaxed = [x for x in cleaned_unresolved if not re.search(r"\[.*\]$", x)]
399
+ if relaxed and relaxed != cleaned_unresolved:
400
+ resolved2, strat2 = _compile_or_pass_through(
401
+ relaxed, strict_cutoff=False, py_ver=candidate_version
402
+ )
403
+ if resolved2 != relaxed or "cutoff=none" in strat2:
404
+ candidate_resolved, candidate_strategy = resolved2, strat2
405
+
406
+ if not candidate_resolved and cleaned_unresolved:
407
+ candidate_resolved = list(cleaned_unresolved)
408
+
409
+ # H) Validate via dry-run with self-healing retry
410
+ from .blocklist import (
411
+ add_to_blocklist,
412
+ extract_failing_package,
413
+ remove_package_from_requirements,
414
+ should_retry_without_package,
415
+ )
416
+
417
+ candidate_can_install, candidate_dry_run_log = uv_dry_run_install(
418
+ candidate_resolved, python_version=candidate_version, venv_path=candidate_venv_path
419
+ )
420
+
421
+ max_retries = 3
422
+ retry_count = 0
423
+ current_deps = list(candidate_resolved)
424
+
425
+ while (
426
+ not candidate_can_install
427
+ and retry_count < max_retries
428
+ and should_retry_without_package(candidate_dry_run_log)
429
+ ):
430
+ failing_pkg = extract_failing_package(candidate_dry_run_log)
431
+ if not failing_pkg:
432
+ break
433
+
434
+ if add_to_blocklist(failing_pkg):
435
+ logger.info(
436
+ "Self-healing: Blocking '%s' and retrying (attempt %d/%d)",
437
+ failing_pkg,
438
+ retry_count + 1,
439
+ max_retries,
440
+ )
441
+
442
+ current_deps, was_removed = remove_package_from_requirements(current_deps, failing_pkg)
443
+ if not was_removed:
444
+ break
445
+
446
+ candidate_can_install, candidate_dry_run_log = uv_dry_run_install(
447
+ current_deps, python_version=candidate_version, venv_path=candidate_venv_path
448
+ )
449
+ retry_count += 1
450
+
451
+ if retry_count > 0:
452
+ candidate_resolved = current_deps
453
+ if retry_count > 0 and candidate_can_install:
454
+ candidate_strategy = f"{candidate_strategy} (self-healed: {retry_count} pkgs removed)"
455
+
456
+ python_version = candidate_version
457
+ resolved_dependencies = candidate_resolved
458
+ resolution_strategy = candidate_strategy
459
+ can_install = candidate_can_install
460
+ dry_run_log = candidate_dry_run_log
461
+
462
+ if can_install:
463
+ ok_real, real_log = uv_install_real(candidate_resolved, python_executable=python_exe.as_posix())
464
+ if ok_real:
465
+ found_flag = True
466
+ logger.debug("Success with Python %s (preflight install ok)!", candidate_version)
467
+ break
468
+ else:
469
+ logger.debug(
470
+ "Dry-run ok but real install failed on Python %s; trying older version.\n%s",
471
+ candidate_version,
472
+ real_log[-800:],
473
+ )
474
+ can_install = False
475
+ dry_run_log = real_log
476
+
477
+ log_lower = dry_run_log.lower()
478
+ is_abi_error = (
479
+ "python abi tag" in log_lower
480
+ or "cp3" in dry_run_log
481
+ or ("no wheels" in log_lower and "python" in log_lower)
482
+ or "cannot install on python version" in log_lower
483
+ or "only versions" in log_lower
484
+ )
485
+
486
+ if is_abi_error:
487
+ logger.debug("ABI incompatibility with Python %s, trying older version", candidate_version)
488
+ continue
489
+ else:
490
+ logger.debug("Non-ABI error with Python %s, stopping attempts", candidate_version)
491
+ break
492
+
493
+ if not python_version:
494
+ logger.debug("No Python version succeeded")
495
+ return None
496
+
497
+ # I) Final identity
498
+ excluded_missing_on_pypi = {}
499
+ excluded_exists_incompatible = {}
500
+ excluded_other = {}
501
+
502
+ commit_info = {
503
+ "sha": sha,
504
+ "repo_name": repo_name,
505
+ "package_name": primary_meta.name,
506
+ "package_version": primary_meta.version,
507
+ "python_version": python_version,
508
+ "build_command": list(cfg_items.build_commands),
509
+ "install_command": list(cfg_items.install_commands),
510
+ "final_dependencies": list(dict.fromkeys(resolved_dependencies)),
511
+ "can_install": can_install,
512
+ "dry_run_log": dry_run_log,
513
+ "primary_root": primary_root,
514
+ "resolution_strategy": resolution_strategy,
515
+ "excluded_missing_on_pypi": excluded_missing_on_pypi,
516
+ "excluded_exists_incompatible": excluded_exists_incompatible,
517
+ "excluded_other": excluded_other,
518
+ }
519
+
520
+ return commit_info
521
+ finally:
522
+ _cleanup_checkout()