diff --git a/astropy_integration/dashboard.py b/astropy_integration/dashboard.py index 790d299..7775edb 100644 --- a/astropy_integration/dashboard.py +++ b/astropy_integration/dashboard.py @@ -68,6 +68,7 @@ def _variant_meta(variant, python, data): "python_version": data.get("python_version") or python, "started_at": data["started_at"], "finished_at": data["finished_at"], + "installed_deps": data.get("installed_deps") or {}, } diff --git a/astropy_integration/run.py b/astropy_integration/run.py index b0dda3c..49cc0d2 100644 --- a/astropy_integration/run.py +++ b/astropy_integration/run.py @@ -2,7 +2,9 @@ Creates a single shared venv, installs astropy first then each package from packages.yaml in order, recording per-package install outcome -(installed / skipped / install-fail / no-spec). Then runs +(installed / skipped / install-fail / no-spec). Each install is +constrained so it can never downgrade a package already in the venv; +a package needing an older version is reported as skipped. Then runs `pytest --pyargs ` for each successfully-installed package. Writes results/__.json with the full venv freeze and per-package data. @@ -223,6 +225,29 @@ def _freeze(python): return out +def _write_no_downgrade_constraints(python, path): + """Pin every installed package to '>=' its current version. + + Passed as a uv `--constraint` file to later installs so a new package + can pull its deps forward but never downgrade what the shared venv + already has; a package that genuinely needs an older version then + shows up as a resolver conflict (skipped) instead of silently + poisoning the venv for everything tested afterwards. + """ + frozen = _freeze(python) + lines = [] + for name, ver in sorted(frozen.items()): + try: + # Drop any PEP 440 local-version segment (e.g. the '+g1a2b3c4' + # on astropy/pyerfa nightly wheels): uv rejects a '>=' specifier + # with a local segment and would fail to parse the whole file. + public = Version(ver).public + except InvalidVersion: + continue + lines.append(f"{name}>={public}") + Path(path).write_text("\n".join(lines) + ("\n" if lines else "")) + + def _load_packages(path): raw = yaml.safe_load(Path(path).read_text()) or {} return list(raw.get("packages", [])) @@ -313,6 +338,7 @@ def run_variant(variant, python_version, packages, repo_root, results_dir, timeo return result, out_path python = os.path.join(venv, "bin", "python") result["python_version"] = _venv_python_version(python) + constraints_path = os.path.join(tmpdir, "no-downgrade-constraints.txt") common = ["uv", "pip", "install", "--python", python, "-q"] for url in astropy["extra_index_urls"]: @@ -339,6 +365,7 @@ def run_variant(variant, python_version, packages, repo_root, results_dir, timeo result["astropy"]["version"] = ( _pkg_version(python, "astropy") or result["astropy"]["version"] ) + _write_no_downgrade_constraints(python, constraints_path) installed_pkgs = [] for pkg, install_spec, target_version in pkg_specs: @@ -364,13 +391,18 @@ def run_variant(variant, python_version, packages, repo_root, results_dir, timeo continue print(f"\nInstalling {pkg['pypi_name']}...") - install_cmd = common + [install_spec] + (pkg.get("extra_deps") or []) + install_cmd = ( + common + + ["--constraint", constraints_path, install_spec] + + (pkg.get("extra_deps") or []) + ) rc, err = _run_install(install_cmd, timeouts["install"]) if rc == 0: entry["install_status"] = status.INSTALLED entry["resolved_version"] = _pkg_version(python, pkg["pypi_name"]) print(f" installed at {entry['resolved_version']}") installed_pkgs.append((pkg, entry)) + _write_no_downgrade_constraints(python, constraints_path) else: entry["install_error"] = err if _resolver_conflict(err): @@ -467,12 +499,20 @@ def run(args): # instead of buffering until the script exits. sys.stdout.reconfigure(line_buffering=True) - packages = _load_packages(args.config) + all_packages = _load_packages(args.config) + packages = all_packages if args.tiers: wanted_tiers = {t.strip() for t in args.tiers.split(",") if t.strip()} packages = [p for p in packages if p.get("tier", "coordinated") in wanted_tiers] if args.packages: wanted = {n.strip() for n in args.packages.split(",") if n.strip()} + known = {p["pypi_name"] for p in all_packages} + unknown = wanted - known + if unknown: + sys.exit( + f"Unknown package name(s): {', '.join(sorted(unknown))}. " + f"Valid names are the pypi_name entries in {args.config}." + ) packages = [p for p in packages if p["pypi_name"] in wanted] packages = _install_order(packages) diff --git a/astropy_integration/templates/_style.css b/astropy_integration/templates/_style.css index 8fb1755..47940ec 100644 --- a/astropy_integration/templates/_style.css +++ b/astropy_integration/templates/_style.css @@ -32,3 +32,22 @@ details summary { cursor: pointer; padding: 4px 0; font-weight: 600; } nav { margin-bottom: 1.5em; font-size: 0.9em; } .legend { font-size: 0.85em; color: #6a737d; margin-top: 1em; } .legend .pill { margin: 0 0.3em; } + +@media (prefers-color-scheme: dark) { + body { background: #0d1117; color: #c9d1d9; } + a { color: #58a6ff; } + th, td { border-color: #30363d; } + th { background: #161b22; } + th.python-group { border-bottom-color: #484f58; } + .banner { background: #2b1d00; border-color: #6e5500; } + tr.tier-header th { background: #161b22; color: #8b949e; } + .meta dl { color: #8b949e; } + .matrix tr:hover td { background: #161b22; } + .pill.pass { background: #0a3a17; color: #3fb950; } + .pill.fail { background: #4a1216; color: #f85149; } + .pill.no-tests { background: #21262d; color: #8b949e; } + .pill.skipped { background: #21262d; color: #8b949e; } + .pill.install-fail{ background: #3d2900; color: #e3b341; } + .pill.timeout { background: #261c4a; color: #a371f7; } + .pill.missing { background: #161b22; color: #6e7681; } +} diff --git a/astropy_integration/templates/index.html b/astropy_integration/templates/index.html index 3602b77..de253e3 100644 --- a/astropy_integration/templates/index.html +++ b/astropy_integration/templates/index.html @@ -96,6 +96,23 @@

Test output

No failures — every package passed in every variant that ran.

{% endif %} +{% if variants_meta|selectattr("installed_deps")|list %} +

Installed packages

+

Full uv pip freeze of the shared venv for each variant that ran.

+{% for v in variants_meta %} +{% if v.installed_deps %} +
+ + {{ v.variant }} / Python {{ v.python }} + ({{ v.installed_deps|length }} packages) + +
{% for name, ver in v.installed_deps|dictsort %}{{ name }}=={{ ver }}
+{% endfor %}
+
+{% endif %} +{% endfor %} +{% endif %} +