Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions astropy_integration/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {},
}


Expand Down
46 changes: 43 additions & 3 deletions astropy_integration/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <module>` for each successfully-installed package.
Writes results/<variant>__<python>.json with the full venv freeze and
per-package data.
Expand Down Expand Up @@ -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", []))
Expand Down Expand Up @@ -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"]:
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
19 changes: 19 additions & 0 deletions astropy_integration/templates/_style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
17 changes: 17 additions & 0 deletions astropy_integration/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ <h3>Test output</h3>
<p class="legend">No failures &mdash; every package passed in every variant that ran.</p>
{% endif %}

{% if variants_meta|selectattr("installed_deps")|list %}
<h2>Installed packages</h2>
<p class="legend">Full <code>uv pip freeze</code> of the shared venv for each variant that ran.</p>
{% for v in variants_meta %}
{% if v.installed_deps %}
<details>
<summary>
<code>{{ v.variant }}</code> / Python <code>{{ v.python }}</code>
<small style="color:#6a737d">({{ v.installed_deps|length }} packages)</small>
</summary>
<pre>{% for name, ver in v.installed_deps|dictsort %}{{ name }}=={{ ver }}
{% endfor %}</pre>
</details>
{% endif %}
{% endfor %}
{% endif %}

<script>
function openTarget() {
const id = location.hash.slice(1);
Expand Down
43 changes: 36 additions & 7 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
"""Repo-level pytest plugin used during PR-preview runs.
"""Repo-level pytest plugin, auto-discovered because the runner invokes
every `pytest --pyargs <module>` with cwd set to the repo root.

When the env var `PYTEST_LIMIT_N` is set to a positive integer, pytest
collects normally and then truncates the test list to the first N
items. This lets the PR matrix surface a fast smoke signal per
package (typically 10 tests each) without per-package configuration.
Two jobs:

Discovered automatically by pytest because cwd is the repo root for
every `pytest --pyargs <module>` invocation in the runner.
1. PR-preview smoke runs: when the env var `PYTEST_LIMIT_N` is set to a
positive integer, pytest collects normally and then truncates the
test list to the first N items. This lets the PR matrix surface a
fast smoke signal per package (typically 10 tests each) without
per-package configuration.

2. Supply shared test fixtures that a package defines in its own
repo-root conftest.py but does not ship in the installed wheel.
`pytest --pyargs` collects from site-packages, so such fixtures are
otherwise missing and their tests error at setup. Currently:
`tmp_cwd` (astroquery).
"""

import os
from pathlib import Path

import pytest


def pytest_collection_modifyitems(config, items):
Expand All @@ -22,3 +32,22 @@ def pytest_collection_modifyitems(config, items):
return
if n > 0 and len(items) > n:
del items[n:]


@pytest.fixture(scope="function")
def tmp_cwd(tmp_path):
"""astroquery shim: run the test in a pristine temp working directory.

Exists solely for astroquery, which defines this fixture in its
repo-root conftest.py. That file is not part of the installed
package, so the fixture is missing under `pytest --pyargs
astroquery` and the esa/utils, esa/iso and esa/xmm_newton download
tests error out at setup. Remove this if astroquery ever ships the
fixture inside the package.
"""
old_dir = Path.cwd()
os.chdir(tmp_path)
try:
yield tmp_path
finally:
os.chdir(old_dir)
14 changes: 10 additions & 4 deletions packages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ packages:
module: astroquery
repo_url: https://github.com/astropy/astroquery.git
install_extras: [test, all]
# test_raises_deprecation_warning uses pytest.raises() on a warning,
# which only works under astroquery's own filterwarnings=error config;
# that setup.cfg config is not picked up when running --pyargs from
# the harness rootdir, so the test cannot pass here.
pytest_args: ["-k", "not test_raises_deprecation_warning"]

- pypi_name: ccdproc
tier: coordinated
Expand Down Expand Up @@ -104,10 +109,11 @@ packages:
module: astroalign
repo_url: https://github.com/quatrope/astroalign.git

- pypi_name: astroML
tier: affiliated
module: astroML
repo_url: https://github.com/astroML/astroML.git
# astroML is no longer maintained, so it is excluded from the matrix.
# - pypi_name: astroML
# tier: affiliated
# module: astroML
# repo_url: https://github.com/astroML/astroML.git

- pypi_name: astroplan
tier: affiliated
Expand Down