diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6b0c235..c765c24 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,9 @@ updates: directory: ".github/workflows" # Location of package manifests schedule: interval: "weekly" + cooldown: + # Let a release settle before Dependabot proposes bumping to it. + default-days: 7 groups: actions: patterns: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..477eecc --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,132 @@ +name: integration-matrix + +on: + schedule: + - cron: '0 6 * * 0' # Sundays at 06:00 UTC + workflow_dispatch: + pull_request: + +permissions: + contents: write # to push gh-pages on main / schedule / dispatch + pull-requests: none # explicit: this workflow never touches PRs + +concurrency: + # One in-flight run per PR, one per main/dispatch invocation. + # PR runs cancel previous in-progress ones; main runs queue. + group: integration-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + variant: + strategy: + fail-fast: false + matrix: + # Keep in sync with `python_versions` in packages.yaml. + # (free-threaded builds: use the "t" suffix, e.g. "3.14t".) + python: ["3.12", "3.14"] + variant: [stable, pre, dev] + runs-on: ubuntu-latest + timeout-minutes: 240 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + # This Python only runs the `astropy-integration` orchestrator CLI; + # the test venv for `matrix.python` is built separately by uv (see + # the `run` step below), so a fixed version is fine here. + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3.2.4 + with: + # We manage the cache ourselves via actions/cache below; turning + # off setup-uv's own caching avoids it saving a duplicate of the + # same directory under its own key. + enable-cache: false + + - name: Cache ~/.cache/uv + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/uv + # Primary key uses the run id, so every run writes a fresh + # snapshot (after `uv cache prune --ci` slims it to wheels + # actually used in this run). The restore-keys are prefix + # matches against saved keys, so the first one always finds + # the most recent run's cache for the same (os, variant, + # python) and the next run starts warm. + key: uv-${{ runner.os }}-${{ matrix.variant }}-${{ matrix.python }}-${{ github.run_id }} + restore-keys: | + uv-${{ runner.os }}-${{ matrix.variant }}-${{ matrix.python }}- + uv-${{ runner.os }}-${{ matrix.variant }}- + uv-${{ runner.os }}- + + - name: Install astropy-integration + run: pip install . + + - name: Run ${{ matrix.variant }} variant on Python ${{ matrix.python }} + env: + # PR previews limit each package to the first 10 tests so the + # matrix finishes in minutes; conftest.py at the repo root reads + # this env var and truncates the collected items. + PYTEST_LIMIT_N: ${{ github.event_name == 'pull_request' && '10' || '' }} + run: astropy-integration run --variant ${{ matrix.variant }} --python ${{ matrix.python }} + + - name: Prune uv cache before save + if: always() + # Strips pre-built sdists and other entries uv won't reuse, so + # the snapshot the cache step uploads stays lean. + run: uv cache prune --ci + + - name: Upload results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: results-${{ matrix.variant }}-${{ matrix.python }} + path: results/${{ matrix.variant }}__${{ matrix.python }}.json + + dashboard: + needs: variant + if: always() + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Install astropy-integration + run: pip install . + + - name: Download all variant results + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: results-* + merge-multiple: true + path: results/ + + - name: Build dashboard + run: astropy-integration dashboard + + # PR path: upload index.html as a non-zipped artifact for the + # in-browser preview (linked from a PR check by preview-link.yml). + - name: Upload preview artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dashboard-preview + path: site/index.html + archive: false + overwrite: true + + # Main / schedule / dispatch path: publish to gh-pages. + - name: Publish to gh-pages + if: github.event_name != 'pull_request' + uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site + force_orphan: true + commit_message: Update integration dashboard diff --git a/.github/workflows/integration_testing.yml b/.github/workflows/integration_testing.yml deleted file mode 100644 index 8a1b737..0000000 --- a/.github/workflows/integration_testing.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: astropy_rc_basic - -on: - workflow_dispatch: - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -# These only prove that basic test suite in Linux works. -# It is up to the individual packages to do detailed testing with astropy RC. -jobs: - test: - uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@d9b81a07e789d1b1921ab5fe33de2ddcbbd02c3f # v2.3.0 - with: - submodules: false - envs: | - - linux: py311-asdf_astropy - - linux: py311-asdf_astropy-dev - - linux: py311-astropy_healpix - - linux: py311-astropy_healpix-dev - - linux: py311-ccdproc - - linux: py311-ccdproc-dev - - linux: py311-photutils - - linux: py311-photutils-dev - - linux: py311-regions - - linux: py311-regions-dev - - linux: py311-reproject - - linux: py311-reproject-dev - - linux: py311-specreduce - - linux: py311-specreduce-dev - - linux: py311-specutils - - linux: py311-specutils-dev - - linux: py312-sunpy - - linux: py312-sunpy-dev diff --git a/.github/workflows/preview-link.yml b/.github/workflows/preview-link.yml new file mode 100644 index 0000000..5a9b47f --- /dev/null +++ b/.github/workflows/preview-link.yml @@ -0,0 +1,30 @@ +name: preview-link + +# Companion to `integration-matrix`. When that workflow finishes on +# a pull request, this one attaches a status check to the source +# commit whose "Details" link goes directly to the dashboard-preview +# artifact (rendered HTML), skipping the artifact summary page. +# +# Must live on the default branch to take effect. + +on: + workflow_run: + workflows: ["integration-matrix"] + types: [requested, in_progress, completed] + +permissions: {} + +jobs: + redirect: + runs-on: ubuntu-latest + permissions: + statuses: write # to attach a status check to the source commit + actions: read # to look up the source workflow's artifact id + steps: + - uses: agriyakhetarpal/github-actions-artifacts-redirector-action@683d25ace2cb0aefe8e6719c39c2ac7f3d22dd8c # v1.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # With `archive: false`, artifact-name is the uploaded file's + # basename without extension (we upload site/index.html). + artifact-name: index + job-title: View dashboard preview diff --git a/.gitignore b/.gitignore index e31e2f6..d581eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ -.tox -.hypothesis -result_images +.tmp/ +results/ +site/ +__pycache__/ +*.pyc +*.egg-info/ +build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7417a4f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.12 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/README.md b/README.md index 65f1d8e..b1a9335 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,148 @@ Integration testing for the Astropy ecosystem ============================================= -[![Integration Status](https://github.com/astropy/astropy-integration-testing/workflows/astropy_rc_basic/badge.svg)](https://github.com/astropy/astropy-integration-testing/actions) +[![Integration matrix](https://github.com/astropy/astropy-integration-testing/actions/workflows/integration.yml/badge.svg)](https://github.com/astropy/astropy-integration-testing/actions/workflows/integration.yml) -This repository is a way to do integration testing -across the Astropy ecosystem to ensure that the core and coordinated packages -work well together. +Cross-ecosystem integration tests for the Astropy core and coordinated +packages. Individual packages should still test against dev/pre-release +astropy in their own CI; the goal here is to catch issues that only +appear when many packages are installed together. -The tests here only do basic testings for those packages on Linux. -Individual packages should still do the due diligence to test against -dev and/or pre-release versions of `astropy` on their own to be sure. +The dashboard is published to +[astropy.github.io/astropy-integration-testing](https://astropy.github.io/astropy-integration-testing/) +after each scheduled run. -To run these tests on GitHub Action as a maintainer of this repo: +How it works +------------ -1. Goto Actions tab. -2. Select `astropy_rc_basic` job. -3. Click "Run workflow" dropdown and then the green "Run workflow" button. -4. A new run should kick off after a few seconds. Monitor the logs of this run. +The `variant` job runs on a schedule (and on `workflow_dispatch`) as a +matrix over astropy variant and Python version. The three variants are: + +| Variant | Astropy | Each package | +|----------|-----------------------------------------------------|----------------------------------------| +| `stable` | Latest non-pre-release on PyPI | Latest non-pre-release on PyPI | +| `pre` | Latest including pre-releases (`--prerelease=allow`)| Latest including pre-releases | +| `dev` | Latest dev wheel from the astropy/simple channel | `git+` (HEAD of main branch) | + +Within each matrix job, a single shared venv is built and packages are +installed one at a time in a deterministic order (coordinated first, +alphabetical within each tier). If a package can't be installed +alongside the existing venv (e.g., it pins `astropy<7` but we already +installed astropy 8), it's skipped and recorded; the rest of the venv +is untouched. After installs, `pytest --pyargs ` runs for each +package that installed successfully. + +The `dashboard` job then downloads the per-matrix-job result JSONs and +publishes the dashboard to `gh-pages`. + +What's in the repo +------------------ + +| File | Purpose | +|---------------------------------------|------------------------------------------------------| +| `packages.yaml` | The list of packages tested + `python_versions` to test against. | +| `astropy_integration/run.py` | Runs one or more (variant, python) combos: resolve specs, install, test, write `results/__.json`. | +| `astropy_integration/dashboard.py` | Reads `results/*.json`, renders `site/index.html` (single self-contained page). | +| `astropy_integration/cli.py` | Console entry point that dispatches the `run` and `dashboard` subcommands. | +| `astropy_integration/status.py` | Shared status vocabulary (used by both `run` and `dashboard`). | +| `astropy_integration/templates/` | HTML/CSS for the dashboard (loaded as package data). | +| `pyproject.toml` | Package metadata; declares the `astropy-integration` console script. | +| `conftest.py` | Repo-root pytest plugin that caps each package to the first `PYTEST_LIMIT_N` tests for PR previews. | +| `.github/workflows/integration.yml` | The matrix workflow (variant x python + dashboard). | +| `.github/workflows/preview-link.yml` | Companion that posts the "View dashboard preview" status check on PRs. | +| `sunpy_pytest.ini` | Custom pytest config referenced by sunpy's `pytest_args` (sunpy's own config requires plugins we don't install). | + +Running locally +--------------- + +```bash +pip install . +# uv is required; see https://docs.astral.sh/uv/ + +# Run one variant. Each variant takes 30-90 min depending on package count. +astropy-integration run --variant stable + +# Or a single package, to iterate faster: +astropy-integration run --variant stable --packages reproject + +# Or a tier subset (default: all tiers run): +astropy-integration run --variant stable --tiers coordinated,other + +# Build the dashboard from whatever results/*.json files exist: +astropy-integration dashboard + +# Preview locally: +python -m http.server -d site 8000 +``` + +Results land in `results/__.json`; the dashboard in +`site/`. Both directories are gitignored. + +Python versions +--------------- + +`packages.yaml` has a top-level `python_versions` list (uv notation, +so `"3.14t"` means the free-threaded 3.14 build): + +```yaml +python_versions: + - "3.12" + - "3.14t" +``` + +The runner tests every (variant x python_version) combination. The +dashboard renders Python versions as grouped header columns above the +three variants. **Keep the `matrix.python` list in +`.github/workflows/integration.yml` in sync** with this — the CI uses +its own matrix because GitHub Actions can't read it from YAML directly. + +Adding or disabling a package +----------------------------- + +Edit `packages.yaml`. Each entry takes: + +- `pypi_name` (the package's name on PyPI; also used as the row label) +- `tier` (label used for ordering and the `--tiers` filter; conventional + values are `coordinated`, `affiliated`, `pyopensci`, `other`) +- `module` (the top-level Python module name, for `pytest --pyargs`) +- `repo_url` (for the `dev` variant install) +- `install_extras` (list, e.g. `[test, all]`) +- `extra_deps` (optional list of extra packages to add to the install) +- `pytest_args` (optional list passed through to pytest; use `-k "not foo"` to skip tests) + +Every entry runs by default. Use `--tiers ` on the runner to +restrict to a tier subset (e.g. `--tiers coordinated`). + +Triggering a run from GitHub +---------------------------- + +1. Actions tab -> `integration-matrix` workflow. +2. "Run workflow" dropdown -> green button. +3. The matrix expands to `len(variants) x len(python_versions)` + parallel jobs; the `dashboard` job waits for them and publishes + to `gh-pages`. + +PR previews +----------- + +`integration-matrix` also runs on pull requests. Same three-variant +matrix as the scheduled run, just with a different final step: the +`dashboard` job uploads `site/index.html` as a non-zipped artifact +(`actions/upload-artifact@v7` with `archive: false`) instead of +publishing to gh-pages. The companion `preview-link` workflow +attaches a "View dashboard preview" status check to the commit +whose "Details" link opens the rendered page directly in the +browser. + +This means the PR preview reflects *this PR's actual matrix run*, +not last main's data. To keep PR feedback fast, each package is +capped at the first 10 collected tests (via `PYTEST_LIMIT_N=10`, +applied by the repo-level `conftest.py`); the preview is a smoke +check of layout, install resolution, and the workflow itself, not +a full regression signal. Concurrency cancels in-progress PR runs +when a new push lands, so only the latest push consumes CI time. + +`preview-link.yml` lives at `.github/workflows/preview-link.yml` +and must be on the default branch for its `workflow_run` trigger +to fire. The first PR after merging the workflow won't get the +status check. diff --git a/astropy_integration/__init__.py b/astropy_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/astropy_integration/cli.py b/astropy_integration/cli.py new file mode 100644 index 0000000..39e1e0f --- /dev/null +++ b/astropy_integration/cli.py @@ -0,0 +1,32 @@ +"""Console entry point: dispatches `run` and `dashboard` subcommands.""" + +import argparse + +from . import dashboard, run + + +def main(): + parser = argparse.ArgumentParser(prog="astropy-integration") + sub = parser.add_subparsers(dest="cmd", required=True) + + run_p = sub.add_parser( + "run", + help="Run one or more (variant, python) cells of the matrix.", + description=run.__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + run.add_arguments(run_p) + + dash_p = sub.add_parser( + "dashboard", + help="Build the dashboard HTML from results/*.json.", + description=dashboard.__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + dashboard.add_arguments(dash_p) + + args = parser.parse_args() + if args.cmd == "run": + run.run(args) + elif args.cmd == "dashboard": + dashboard.run(args) diff --git a/astropy_integration/dashboard.py b/astropy_integration/dashboard.py new file mode 100644 index 0000000..790d299 --- /dev/null +++ b/astropy_integration/dashboard.py @@ -0,0 +1,210 @@ +"""Build the single-page integration-matrix dashboard. + +Reads every results/__.json that `astropy-integration run` +wrote and emits a single self-contained `site/index.html` with: + + - one row per package + - one column group per Python version, subdivided into the three + astropy variants (stable / pre / dev) + - a "Failure logs" section at the bottom with collapsible
+ for every non-passing cell, anchored from the matching badge +""" + +import json +import re +import shutil +from itertools import groupby +from pathlib import Path + +from jinja2 import Environment, PackageLoader, select_autoescape + +from . import status + + +def _anchor_id(name, variant, python): + # Anchors are only used inside the page so we just need + # something HTML-id-safe and unique per (pkg, variant, python). + safe_python = re.sub(r"[^A-Za-z0-9]", "_", python) + return f"log-{name}__{variant}__{safe_python}" + + +def _load_results(results_dir): + """Return {(variant, python): data, ...} for every results/*.json found.""" + by_combo = {} + for f in sorted(results_dir.glob("*.json")): + if f.name.startswith("_"): + continue + data = json.loads(f.read_text()) + v = data.get("variant") + p = data.get("python_requested") or data.get("python_version") or "" + if not v or not p: + continue + by_combo[(v, p)] = data + return by_combo + + +def _column_groups(by_combo): + """Discover Python versions and order columns. + + Returns: + pythons: sorted list of Python version strings (preserves config-style + ordering: shorter strings first, then alphabetical). + columns: flat list of (python, variant) tuples in display order. + """ + pythons = sorted({p for _, p in by_combo}, key=lambda s: (len(s), s)) + columns = [(p, v) for p in pythons for v in status.VARIANTS] + return pythons, columns + + +def _variant_meta(variant, python, data): + if not data: + return {"variant": variant, "python": python, "has_data": False} + return { + "variant": variant, + "python": python, + "has_data": True, + "astropy_version": data["astropy"]["version"], + "extra_index_urls": data["astropy"].get("extra_index_urls") or [], + "python_version": data.get("python_version") or python, + "started_at": data["started_at"], + "finished_at": data["finished_at"], + } + + +def _ordered_packages(by_combo): + """Package names in the order they first appear across all results.""" + seen = [] + seen_set = set() + for _, data in by_combo.items(): + for entry in data["packages"]: + if entry["name"] not in seen_set: + seen.append(entry["name"]) + seen_set.add(entry["name"]) + return seen + + +def _make_rows(by_combo, names, columns): + rows = [] + for name in names: + cells = [] + row_tier = None + for python, variant in columns: + data = by_combo.get((variant, python)) + entry = None + if data: + entry = next((e for e in data["packages"] if e["name"] == name), None) + if entry is None: + cells.append( + { + "status": "missing", + "label": "-", + "anchor": "", + "resolved_version": "", + } + ) + continue + if row_tier is None: + row_tier = entry.get("tier", "coordinated") + badge = status.cell_badge(entry) + anchor = ( + _anchor_id(name, variant, python) + if badge["status"] not in ("pass", "missing") + else "" + ) + cells.append( + { + **badge, + "anchor": anchor, + "resolved_version": entry.get("resolved_version", ""), + } + ) + rows.append({"name": name, "tier": row_tier or "coordinated", "cells": cells}) + return rows + + +def _group_rows_by_tier(rows): + """Group rows by tier, preserving order. Returns [(tier, [rows]), ...].""" + return [ + (tier, list(group)) for tier, group in groupby(rows, key=lambda r: r["tier"]) + ] + + +def _failures(by_combo, columns): + """Per-cell failure detail (full logs), ordered by column then row.""" + out = [] + for python, variant in columns: + data = by_combo.get((variant, python)) + if not data: + continue + for entry in data["packages"]: + badge = status.cell_badge(entry) + if badge["status"] in ("pass", "missing"): + continue + out.append( + { + "name": entry["name"], + "variant": variant, + "python": python, + "status": badge["status"], + "label": badge["label"], + "install_error": entry.get("install_error", ""), + "test_output": entry.get("test_output", ""), + "anchor": _anchor_id(entry["name"], variant, python), + } + ) + return out + + +def build(results_dir, output_dir): + results_dir = Path(results_dir) + output_dir = Path(output_dir) + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True) + + env = Environment( + loader=PackageLoader("astropy_integration", "templates"), + autoescape=select_autoescape(["html"]), + ) + + by_combo = _load_results(results_dir) + pythons, columns = _column_groups(by_combo) + names = _ordered_packages(by_combo) + rows = _make_rows(by_combo, names, columns) + tier_groups = _group_rows_by_tier(rows) + failures = _failures(by_combo, columns) + variants_meta = [ + _variant_meta(v, p, by_combo.get((v, p))) + for p in pythons + for v in status.VARIANTS + ] + + # If any variant ran with a per-package test limit, surface it in + # a banner; PR previews use this to keep wall time bounded. + limits = { + d.get("pytest_limit_n") + for d in by_combo.values() + if d and d.get("pytest_limit_n") + } + pytest_limit = min(limits) if limits else None + + (output_dir / "index.html").write_text( + env.get_template("index.html").render( + pythons=pythons, + variants=list(status.VARIANTS), + variants_meta=variants_meta, + tier_groups=tier_groups, + failures=failures, + pytest_limit=pytest_limit, + ) + ) + print(f"Wrote {output_dir}/index.html") + + +def add_arguments(ap): + ap.add_argument("--results-dir", default="results") + ap.add_argument("--output", default="site") + + +def run(args): + build(args.results_dir, args.output) diff --git a/astropy_integration/run.py b/astropy_integration/run.py new file mode 100644 index 0000000..b0dda3c --- /dev/null +++ b/astropy_integration/run.py @@ -0,0 +1,501 @@ +"""Run one variant of the astropy ecosystem integration matrix. + +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 +`pytest --pyargs ` for each successfully-installed package. +Writes results/__.json with the full venv freeze and +per-package data. + +Usage: + astropy-integration run # full matrix (all variants x all Python versions from config) + astropy-integration run --variant stable # one variant, all configured Pythons + astropy-integration run --variant stable --python 3.12 # one combo + astropy-integration run --python 3.14t # all variants on free-threaded 3.14 + astropy-integration run --variant stable --packages reproject,sunpy + astropy-integration run --variant stable --tiers coordinated +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path + +import yaml +from packaging.version import InvalidVersion, Version + +from . import status + +PYPI_JSON_URL = "https://pypi.org/pypi/{name}/json" +ASTROPY_NIGHTLY_INDEX = "https://pypi.anaconda.org/astropy/simple" +LIBERFA_NIGHTLY_INDEX = ( + "https://pypi.anaconda.org/liberfa/simple" # for pyerfa dev wheels +) + + +def _http_json(url, timeout=20): + with urllib.request.urlopen(url, timeout=timeout) as r: + return json.load(r) + + +def _version_key(v): + try: + return Version(v) + except InvalidVersion: + return Version("0") + + +def _is_prerelease(v): + try: + return Version(v).is_prerelease + except InvalidVersion: + return True + + +def _yanked(files): + return all(f.get("yanked") for f in files) if files else True + + +def latest_pypi(name, include_prereleases): + info = _http_json(PYPI_JSON_URL.format(name=name)) + versions = [] + for ver, files in info["releases"].items(): + if _yanked(files): + continue + if not include_prereleases and _is_prerelease(ver): + continue + versions.append(ver) + if not versions and not include_prereleases: + # Fallback: package has only pre-releases on PyPI + return latest_pypi(name, include_prereleases=True) + if not versions: + return None + return sorted(versions, key=_version_key)[-1] + + +def _extras_suffix(pkg): + extras = pkg.get("install_extras") or [] + return "[" + ",".join(extras) + "]" if extras else "" + + +def resolve_specs(packages, variant): + """Resolve the astropy spec and per-package install specs for the variant.""" + if variant == "dev": + # Let uv resolve the latest dev version from the astropy/simple + # channel; we read the installed version back after install. No + # explicit pin avoids the PEP 440 local-version segment headaches + # that astropy's nightly wheels have (e.g. 8.1.0.dev53+gabcdef). + # + # `--index-strategy unsafe-best-match` is required because uv's + # default ("first-index") only considers a single index per + # package; astropy/simple hosts astropy AND pyerfa (the channel's + # latest wheels sometimes ship only musllinux). unsafe-best-match + # lets uv fall back to PyPI when the channel's only wheels don't + # match the runner platform. + astropy = { + "install": "astropy", + "version": "", + "extra_index_urls": [ASTROPY_NIGHTLY_INDEX, LIBERFA_NIGHTLY_INDEX], + "prerelease_strategy": "allow", + "index_strategy": "unsafe-best-match", + } + + def pkg_spec(pkg): + repo = pkg.get("repo_url") + if not repo: + return None, None + return f"{pkg['pypi_name']}{_extras_suffix(pkg)} @ git+{repo}", None + + else: + include_pre = variant == "pre" + astropy_ver = latest_pypi("astropy", include_prereleases=include_pre) + astropy = { + "install": f"astropy=={astropy_ver}", + "version": astropy_ver, + "extra_index_urls": [], + "prerelease_strategy": "allow" + if include_pre + else "if-necessary-or-explicit", + "index_strategy": None, + } + + def pkg_spec(pkg): + ver = latest_pypi(pkg["pypi_name"], include_prereleases=include_pre) + if not ver: + return None, None + return f"{pkg['pypi_name']}{_extras_suffix(pkg)}=={ver}", ver + + pkg_specs = [] + for pkg in packages: + spec, target = pkg_spec(pkg) + pkg_specs.append((pkg, spec, target)) + return astropy, pkg_specs + + +def _resolver_conflict(stderr): + """True if the install stderr looks like a uv resolver conflict.""" + s = stderr.lower() + keywords = ( + "no solution found", + "incompatible", + "conflict", + "could not find a version", + "no matching distribution", + "no version of", + ) + return any(k in s for k in keywords) + + +def ensure_python(version): + proc = subprocess.run( + ["uv", "python", "find", version], capture_output=True, text=True, timeout=60 + ) + if proc.returncode == 0: + return proc.stdout.strip() + inst = subprocess.run( + ["uv", "python", "install", version], + capture_output=True, + text=True, + timeout=600, + ) + if inst.returncode != 0: + sys.exit(f"uv python install {version}: {inst.stderr.strip()}") + proc = subprocess.run( + ["uv", "python", "find", version], capture_output=True, text=True, timeout=60 + ) + if proc.returncode != 0: + sys.exit(f"uv python find {version}: {proc.stderr.strip()}") + return proc.stdout.strip() + + +def _venv_python_version(python): + proc = subprocess.run( + [ + python, + "-c", + "import sys; print('.'.join(str(x) for x in sys.version_info[:3]))", + ], + capture_output=True, + text=True, + timeout=30, + ) + return proc.stdout.strip() if proc.returncode == 0 else "" + + +def _pkg_version(python, name): + proc = subprocess.run( + [ + python, + "-c", + "import importlib.metadata as md, sys; print(md.version(sys.argv[1]))", + name, + ], + capture_output=True, + text=True, + timeout=30, + ) + return proc.stdout.strip() if proc.returncode == 0 else "" + + +def _freeze(python): + proc = subprocess.run( + ["uv", "pip", "freeze", "--python", python], + capture_output=True, + text=True, + timeout=60, + ) + out = {} + if proc.returncode == 0: + for line in proc.stdout.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "==" in line: + n, v = line.split("==", 1) + out[n.lower()] = v + return out + + +def _load_packages(path): + raw = yaml.safe_load(Path(path).read_text()) or {} + return list(raw.get("packages", [])) + + +def _load_python_versions(path): + raw = yaml.safe_load(Path(path).read_text()) or {} + versions = raw.get("python_versions") or [] + if not versions: + versions = ["3.12"] + return [str(v) for v in versions] + + +# Ordering of tiers when installing/displaying. Unknown tiers sort last. +TIER_RANK = {"coordinated": 0, "affiliated": 1, "pyopensci": 2, "other": 3} + + +def _install_order(packages): + """Coordinated, then affiliated, then pyopensci, then other; alphabetical within each tier.""" + return sorted( + packages, + key=lambda p: ( + TIER_RANK.get(p.get("tier", "coordinated"), 9), + p["pypi_name"].lower(), + ), + ) + + +def _run_install(install_cmd, timeout): + """Wrap subprocess.run with TimeoutExpired catch. Returns (rc, stderr_or_msg).""" + try: + proc = subprocess.run( + install_cmd, capture_output=True, text=True, timeout=timeout + ) + except subprocess.TimeoutExpired: + return None, "timeout during install" + return proc.returncode, (proc.stderr or proc.stdout) + + +def run_variant(variant, python_version, packages, repo_root, results_dir, timeouts): + astropy, pkg_specs = resolve_specs(packages, variant) + print(f"\n=== Variant: {variant} (Python {python_version}) ===") + print(f" astropy: {astropy['install']}") + for pkg, spec, target in pkg_specs: + print(f" {pkg['pypi_name']:<20} {spec or '(no install spec)'}") + + result = { + "variant": variant, + "started_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "finished_at": "", + "astropy": { + "install_spec": astropy["install"], + "version": astropy["version"], + "extra_index_urls": astropy["extra_index_urls"], + "prerelease_strategy": astropy["prerelease_strategy"], + }, + "python_requested": python_version, + "python_version": "", + "timeout_test_seconds": timeouts["test"], + "pytest_limit_n": ( + int(os.environ["PYTEST_LIMIT_N"]) + if (os.environ.get("PYTEST_LIMIT_N") or "").isdigit() + else None + ), + "fatal_error": "", + "installed_deps": {}, + "packages": [], + } + out_path = results_dir / f"{variant}__{python_version}.json" + + Path(repo_root, ".tmp").mkdir(exist_ok=True) + tmpdir = tempfile.mkdtemp( + prefix=f"int-{variant}-{python_version}-", + dir=str(Path(repo_root, ".tmp").resolve()), + ) + + try: + py_path = ensure_python(python_version) + venv = os.path.join(tmpdir, "venv") + venv_proc = subprocess.run( + ["uv", "venv", venv, "-p", py_path, "-q"], + capture_output=True, + text=True, + timeout=120, + ) + if venv_proc.returncode != 0: + result["fatal_error"] = f"uv venv: {venv_proc.stderr or venv_proc.stdout}" + return result, out_path + python = os.path.join(venv, "bin", "python") + result["python_version"] = _venv_python_version(python) + + common = ["uv", "pip", "install", "--python", python, "-q"] + for url in astropy["extra_index_urls"]: + common += ["--extra-index-url", url] + common += [f"--prerelease={astropy['prerelease_strategy']}"] + if astropy.get("index_strategy"): + common += [f"--index-strategy={astropy['index_strategy']}"] + + print("\nInstalling astropy + pytest...") + # pytest-remotedata registers the `remote_data` marker many + # astropy ecosystem packages use; with the plugin installed but + # `--remote-data` not passed, those tests are skipped automatically + # instead of running and timing out on network calls. + rc, err = _run_install( + common + + [astropy["install"], "pytest", "pytest-timeout", "pytest-remotedata"], + timeouts["install"], + ) + if rc != 0: + print(" FATAL: astropy install failed") + print(err) + result["fatal_error"] = err + return result, out_path + result["astropy"]["version"] = ( + _pkg_version(python, "astropy") or result["astropy"]["version"] + ) + + installed_pkgs = [] + for pkg, install_spec, target_version in pkg_specs: + entry = { + "name": pkg["pypi_name"], + "tier": pkg.get("tier", "coordinated"), + "module": pkg.get("module", pkg["pypi_name"]), + "install_spec": install_spec, + "target_version": target_version, + "resolved_version": "", + "install_status": "", # installed | skipped | install-fail | no-spec + "install_error": "", + "test_status": "", # pass | fail | no-tests | timeout | not-run + "test_output": "", + "duration": 0, + } + if install_spec is None: + entry["install_status"] = status.NO_SPEC + entry["install_error"] = ( + "no install spec (missing repo_url, or no PyPI release)" + ) + result["packages"].append(entry) + continue + + print(f"\nInstalling {pkg['pypi_name']}...") + install_cmd = common + [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)) + else: + entry["install_error"] = err + if _resolver_conflict(err): + entry["install_status"] = status.SKIPPED + print(" skipped (resolver conflict)") + else: + entry["install_status"] = status.INSTALL_FAIL + print(" install failed") + print(err) + result["packages"].append(entry) + + result["installed_deps"] = _freeze(python) + + for pkg, entry in installed_pkgs: + print(f"\nTesting {pkg['pypi_name']}...") + module = entry["module"] + cmd = [python, "-m", "pytest", "--pyargs", module] + cmd += pkg.get("pytest_args", []) + cmd += ["--timeout=120", "-q", "--tb=line", "--no-header"] + + env = {**os.environ, "MPLBACKEND": "Agg", "DISPLAY": ""} + start = time.time() + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeouts["test"], + cwd=repo_root, + env=env, + ) + except subprocess.TimeoutExpired: + entry["test_status"] = status.TIMEOUT + entry["test_output"] = "timeout" + entry["duration"] = round(time.time() - start, 1) + print(f" timeout after {entry['duration']}s") + continue + entry["duration"] = round(time.time() - start, 1) + out = proc.stdout + if proc.stderr: + out += "\n--- stderr ---\n" + proc.stderr + entry["test_output"] = out + + no_module = proc.returncode == 4 and "module or package not found" in ( + proc.stdout + proc.stderr + ) + if proc.returncode == 5 or no_module: + entry["test_status"] = status.NO_TESTS + elif proc.returncode == 0: + entry["test_status"] = status.PASS + else: + entry["test_status"] = status.FAIL + print(f" {entry['test_status']} in {entry['duration']}s") + + finally: + result["finished_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds") + out_path.write_text(json.dumps(result, indent=2)) + shutil.rmtree(tmpdir, ignore_errors=True) + + return result, out_path + + +def _counts(result): + install = Counter(e["install_status"] for e in result["packages"]) + test = Counter(e["test_status"] for e in result["packages"] if e["test_status"]) + return {"install": dict(install), "test": dict(test)} + + +def add_arguments(ap): + ap.add_argument("--config", default="packages.yaml") + ap.add_argument("--results-dir", default="results") + ap.add_argument( + "--variant", + choices=status.VARIANTS, + help="Variant to run; if omitted, runs all variants in sequence.", + ) + ap.add_argument( + "--python", + help="Python version to run against (e.g. '3.12', '3.14t'); " + "if omitted, runs every version listed in the config.", + ) + ap.add_argument("--packages", help="Comma-separated subset of package names to run") + ap.add_argument( + "--tiers", + help="Comma-separated subset of tiers to run (e.g. 'coordinated,other'); " + "default: all tiers", + ) + ap.add_argument("--timeout-install", type=int, default=900) + ap.add_argument("--timeout-test", type=int, default=1800) + + +def run(args): + # Make every print flush immediately so CI streams progress live + # instead of buffering until the script exits. + sys.stdout.reconfigure(line_buffering=True) + + packages = _load_packages(args.config) + 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()} + packages = [p for p in packages if p["pypi_name"] in wanted] + packages = _install_order(packages) + + repo_root = Path.cwd() + results_dir = Path(args.results_dir) + results_dir.mkdir(parents=True, exist_ok=True) + + timeouts = {"install": args.timeout_install, "test": args.timeout_test} + variants_to_run = [args.variant] if args.variant else list(status.VARIANTS) + pythons_to_run = ( + [args.python] if args.python else _load_python_versions(args.config) + ) + + fatal_combos = [] + for python_version in pythons_to_run: + for variant in variants_to_run: + result, out_path = run_variant( + variant, python_version, packages, repo_root, results_dir, timeouts + ) + print(f"\nDone {variant}/{python_version}: {_counts(result)}") + print(f"Wrote {out_path}") + if result.get("fatal_error"): + fatal_combos.append(f"{variant}/{python_version}") + + if fatal_combos: + sys.exit(f"\nAstropy install failed for: {', '.join(fatal_combos)}") diff --git a/astropy_integration/status.py b/astropy_integration/status.py new file mode 100644 index 0000000..47d8ac5 --- /dev/null +++ b/astropy_integration/status.py @@ -0,0 +1,51 @@ +"""Shared status vocabulary for integration-test results. + +Both `astropy_integration.run` (which writes statuses into results JSON) +and `astropy_integration.dashboard` (which renders them into the +dashboard) use these constants and helpers, so the strings are defined +in exactly one place. +""" + +# The three astropy variants. Listed once here so both scripts agree. +VARIANTS = ("stable", "pre", "dev") + +# install_status values, used in results/.json +INSTALLED = "installed" +SKIPPED = "skipped" # resolver couldn't satisfy alongside the existing venv +INSTALL_FAIL = "install-fail" # install itself crashed (build error, network, etc.) +NO_SPEC = "no-spec" # couldn't build an install spec (no repo_url / no PyPI release) + +# test_status values +PASS = "pass" +FAIL = "fail" +NO_TESTS = "no-tests" +TIMEOUT = "timeout" + + +# Tabular maps used on the per-variant page. +INSTALL_BADGE = { + INSTALLED: {"status": "pass", "label": "installed"}, + SKIPPED: {"status": "skipped", "label": "skipped"}, + INSTALL_FAIL: {"status": "install-fail", "label": "install fail"}, + NO_SPEC: {"status": "missing", "label": "no spec"}, + "": {"status": "missing", "label": "-"}, +} + +TEST_BADGE = { + PASS: {"status": "pass", "label": "PASS"}, + FAIL: {"status": "fail", "label": "FAIL"}, + NO_TESTS: {"status": "no-tests", "label": "no tests"}, + TIMEOUT: {"status": "timeout", "label": "timeout"}, + "": {"status": "missing", "label": "-"}, +} + + +def cell_badge(entry): + """Map a result entry to the {status, label} for its matrix cell. + + Install failures (including no-spec) take precedence over test status. + """ + install = entry.get("install_status", "") + if install != INSTALLED: + return INSTALL_BADGE.get(install, INSTALL_BADGE[""]) + return TEST_BADGE.get(entry.get("test_status", ""), TEST_BADGE[""]) diff --git a/astropy_integration/templates/_base.html b/astropy_integration/templates/_base.html new file mode 100644 index 0000000..769f3ce --- /dev/null +++ b/astropy_integration/templates/_base.html @@ -0,0 +1,12 @@ + + + + +{% block title %}{% endblock %} + + + +{% block nav %}{% endblock %} +{% block body %}{% endblock %} + + diff --git a/astropy_integration/templates/_style.css b/astropy_integration/templates/_style.css new file mode 100644 index 0000000..8fb1755 --- /dev/null +++ b/astropy_integration/templates/_style.css @@ -0,0 +1,34 @@ +body { font-family: -apple-system, system-ui, sans-serif; margin: 2em; line-height: 1.5; color: #1a1a1a; max-width: 1200px; } +h1, h2 { margin-top: 0; } +a { color: #0366d6; text-decoration: none; } +a:hover { text-decoration: underline; } +table { border-collapse: collapse; } +th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; vertical-align: top; } +th { background: #f6f8fa; } +th.python-group { text-align: center; border-bottom: 2px solid #ccc; } +.banner { background: #fff8c5; border: 1px solid #d4a72c; padding: 0.75em 1em; + border-radius: 6px; margin: 1em 0; } +tr.tier-header th { background: #eef2f6; text-align: left; font-weight: 600; + text-transform: lowercase; letter-spacing: 0.5px; color: #444; } +.meta dl { margin: 0.3em 0; display: grid; grid-template-columns: max-content 1fr; gap: 0.2em 1em; color: #444; } +.meta dt { font-weight: 600; } +.pill { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.85em; font-weight: 600; min-width: 4em; text-align: center; } +.pill.pass { background: #dcffe4; color: #22863a; } +.pill.fail { background: #ffdce0; color: #cb2431; } +.pill.no-tests { background: #eef2f6; color: #6a737d; } +.pill.skipped { background: #e6e9ee; color: #6a737d; font-style: italic; } +.pill.install-fail { background: #fff1d6; color: #b08000; } +.pill.timeout { background: #ece2ff; color: #5b3fb8; } +.pill.missing { background: #f1f1f1; color: #999; } +a.cell-link { text-decoration: none; } +a.cell-link:hover .pill { filter: brightness(0.94); } +.matrix tr:hover td { background: #fafafa; } +.pkg-name { font-weight: 600; } +pre { background: #1e1e1e; color: #d4d4d4; padding: 1em; border-radius: 6px; + overflow-x: auto; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; max-height: 50em; } +details summary { cursor: pointer; padding: 4px 0; font-weight: 600; } +.meta-table td { padding: 4px 10px; } +.meta-table { width: auto; } +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; } diff --git a/astropy_integration/templates/index.html b/astropy_integration/templates/index.html new file mode 100644 index 0000000..3602b77 --- /dev/null +++ b/astropy_integration/templates/index.html @@ -0,0 +1,112 @@ +{% extends "_base.html" %} +{% block title %}Astropy integration matrix{% endblock %} +{% block body %} +

Astropy ecosystem integration matrix

+ +{% if pytest_limit %} + +{% endif %} + + + + +{% for v in variants_meta %} + + + + + + + +{% endfor %} + +
PythonVariantAstropyStartedFinished
{{ v.python }}{{ v.variant }}{% if v.has_data %}{{ v.astropy_version }}{% if v.extra_index_urls %} (nightly){% endif %}{% else %}-{% endif %}{% if v.has_data %}{{ v.started_at }}{% else %}-{% endif %}{% if v.has_data %}{{ v.finished_at or "(in progress)" }}{% else %}-{% endif %}
+ +

Matrix

+ + + + + {% for p in pythons %}{% endfor %} + + + {% for p in pythons %}{% for v in variants %}{% endfor %}{% endfor %} + + + +{% for tier, group in tier_groups %} + + + +{% for row in group %} + + + {% for cell in row.cells %} + + {% endfor %} + +{% endfor %} +{% endfor %} + +
PackagePython {{ p }}
{{ v }}
{{ tier|capitalize }}
{{ row.name }} + {% if cell.anchor %} + {{ cell.label }} + {% else %} + {{ cell.label }} + {% endif %} + {% if cell.resolved_version %}
{{ cell.resolved_version }}{% endif %} +
+ +
Legend: + pass + fail + timeout + no tests + skipped + install fail + missing +
+

skipped: resolver couldn't satisfy this package alongside the rest of the venv. install fail: install step crashed for some other reason (build error, etc.).

+ +{% if failures %} +

Failure logs

+

Click any non-passing badge above to jump to its log; the section will auto-expand.

+{% for f in failures %} +
+ + {{ f.name }} + ({{ f.variant }} / Python {{ f.python }}) + {{ f.label }} + + {% if f.install_error %} +

Install error

+
{{ f.install_error }}
+ {% endif %} + {% if f.test_output %} +

Test output

+
{{ f.test_output }}
+ {% endif %} +
+{% endfor %} +{% else %} +

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

+{% endif %} + + +{% endblock %} diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..5180a3f --- /dev/null +++ b/conftest.py @@ -0,0 +1,24 @@ +"""Repo-level pytest plugin used during PR-preview 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. + +Discovered automatically by pytest because cwd is the repo root for +every `pytest --pyargs ` invocation in the runner. +""" + +import os + + +def pytest_collection_modifyitems(config, items): + raw = os.environ.get("PYTEST_LIMIT_N") + if not raw: + return + try: + n = int(raw) + except ValueError: + return + if n > 0 and len(items) > n: + del items[n:] diff --git a/packages.yaml b/packages.yaml new file mode 100644 index 0000000..cc49902 --- /dev/null +++ b/packages.yaml @@ -0,0 +1,380 @@ +# Packages tested by `astropy-integration run`. +# +# One block per package = one row in the dashboard. For each astropy +# variant (stable / pre / dev) and each Python version, the runner +# builds a shared venv, installs astropy first then each package in +# turn (skipping any that the resolver can't satisfy alongside the +# existing set), then runs `pytest --pyargs ` for each one +# that installed. +# +# `tier` is purely a category label used for ordering and the optional +# `--tiers` CLI filter. Conventions: `coordinated` for the official +# coordinated set, `affiliated` for affiliated packages from the frozen +# pre-APE 22 registry, `pyopensci` for affiliated packages reviewed +# through the pyOpenSci process, `other` for anything else worth testing. +# +# `python_versions` is the list of Python versions to test against; +# use uv's notation, so e.g. "3.14t" for the free-threaded build. + +python_versions: + - "3.12" + - "3.14" + +packages: + - pypi_name: asdf_astropy + tier: coordinated + module: asdf_astropy + repo_url: https://github.com/astropy/asdf-astropy.git + install_extras: [test] + + - pypi_name: astropy_healpix + tier: coordinated + module: astropy_healpix + repo_url: https://github.com/astropy/astropy-healpix.git + install_extras: [test] + + - pypi_name: astroquery + tier: coordinated + module: astroquery + repo_url: https://github.com/astropy/astroquery.git + install_extras: [test, all] + + - pypi_name: ccdproc + tier: coordinated + module: ccdproc + repo_url: https://github.com/astropy/ccdproc.git + install_extras: [test, all] + extra_deps: [psutil] + + - pypi_name: photutils + tier: coordinated + module: photutils + repo_url: https://github.com/astropy/photutils.git + install_extras: [test, all] + + - pypi_name: regions + tier: coordinated + module: regions + repo_url: https://github.com/astropy/regions.git + install_extras: [test, all] + + - pypi_name: reproject + tier: coordinated + module: reproject + repo_url: https://github.com/astropy/reproject.git + install_extras: [test, all] + extra_deps: [gwcs] + + - pypi_name: specreduce + tier: coordinated + module: specreduce + repo_url: https://github.com/astropy/specreduce.git + install_extras: [test] + + - pypi_name: specutils + tier: coordinated + module: specutils + repo_url: https://github.com/astropy/specutils.git + install_extras: [all, test] + + - pypi_name: sunpy + tier: other + module: sunpy + repo_url: https://github.com/sunpy/sunpy.git + install_extras: [tests, all] + pytest_args: ["-c", "sunpy_pytest.ini"] + + # --- Affiliated packages auto-imported from + # https://github.com/astropy/astropy.github.com/blob/main/affiliated/registry.json + # Module names defaulted to pypi_name with hyphens/dots replaced by underscores; + # individual packages may need overrides if pytest --pyargs fails to find them. + - pypi_name: agnpy + tier: affiliated + module: agnpy + repo_url: https://github.com/cosimoNigro/agnpy.git + + - pypi_name: APLpy + tier: affiliated + module: aplpy # installed package name is lowercase + repo_url: https://github.com/aplpy/aplpy.git + install_extras: [test] + + - pypi_name: astroalign + tier: affiliated + 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 + + - pypi_name: astroplan + tier: affiliated + module: astroplan + repo_url: https://github.com/astropy/astroplan.git + + - pypi_name: astroscrappy + tier: affiliated + module: astroscrappy + repo_url: https://github.com/astropy/astroscrappy.git + + - pypi_name: baseband + tier: affiliated + module: baseband + repo_url: https://github.com/mhvk/baseband.git + + - pypi_name: BayesicFitting + tier: affiliated + module: BayesicFitting + repo_url: https://github.com/dokester/BayesicFitting.git + # BayesicFitting ships unittest-style tests as Test*.py (capital T); + # override pytest's default `python_files` glob so it picks them up. + pytest_args: ["-o", "python_files=Test*.py"] + + - pypi_name: cluster-lensing + tier: affiliated + module: clusterlensing # installed package has no underscore + repo_url: https://github.com/jesford/cluster-lensing.git + + - pypi_name: corral-pipeline + tier: affiliated + module: corral_pipeline + repo_url: https://github.com/toros-astro/corral.git + + - pypi_name: dust_extinction + tier: affiliated + module: dust_extinction + repo_url: https://github.com/karllark/dust_extinction.git + + - pypi_name: einsteinpy + tier: affiliated + module: einsteinpy + repo_url: https://github.com/einsteinpy/einsteinpy.git + + - pypi_name: feets + tier: affiliated + module: feets + repo_url: https://github.com/carpyncho/feets.git + + - pypi_name: gala + tier: affiliated + module: gala + repo_url: https://github.com/adrn/gala.git + + - pypi_name: galpy + tier: affiliated + module: galpy + repo_url: https://github.com/jobovy/galpy.git + + - pypi_name: gammapy + tier: affiliated + module: gammapy + repo_url: https://github.com/gammapy/gammapy.git + install_extras: [test] + + - pypi_name: ginga + tier: affiliated + module: ginga + repo_url: https://github.com/ejeschke/ginga.git + # `ginga/tests/test_zarr.py` uses removed zarr 3.x API; ignore it + # so the rest of the suite (256 passing tests) can run. + pytest_args: ["--ignore-glob=*test_zarr.py"] + + - pypi_name: glueviz + tier: affiliated + # glueviz is a meta-package; the actual code (and tests) lives in + # glue-core which it pulls in. Its installed module is `glue`. + module: glue + repo_url: https://github.com/glue-viz/glue.git + + - pypi_name: gwcs + tier: affiliated + module: gwcs + repo_url: https://github.com/spacetelescope/gwcs.git + + - pypi_name: halotools + tier: affiliated + module: halotools + repo_url: https://github.com/astropy/halotools.git + + - pypi_name: hendrics + tier: affiliated + module: hendrics + repo_url: https://github.com/StingraySoftware/HENDRICS.git + install_extras: [test] + + - pypi_name: hips + tier: affiliated + module: hips + repo_url: https://github.com/hipspy/hips.git + + - pypi_name: imexam + tier: affiliated + module: imexam + repo_url: https://github.com/spacetelescope/imexam.git + + - pypi_name: kanon + tier: affiliated + module: kanon + repo_url: https://github.com/legau/kanon.git + + - pypi_name: lenstronomy + tier: affiliated + module: lenstronomy + repo_url: https://github.com/lenstronomy/lenstronomy.git + + - pypi_name: ligo.skymap + tier: affiliated + # PEP 420 namespace package: installed as ligo/skymap/, not ligo_skymap. + module: ligo.skymap + repo_url: https://git.ligo.org/lscsoft/ligo.skymap.git + install_extras: [test] + + - pypi_name: linetools + tier: affiliated + module: linetools + repo_url: https://github.com/linetools/linetools.git + install_extras: [test] + # linetools imports pkg_resources (deprecated stdlib alias); pull + # in setuptools so it's available at runtime. + extra_deps: [setuptools] + + - pypi_name: marxs + tier: affiliated + module: marxs + repo_url: https://github.com/Chandra-MARX/marxs.git + install_extras: [test] + # Two test files require an external MARX C library we don't have; + # ignoring them lets the rest of the suite collect. + pytest_args: + - "--ignore-glob=*test_hrma.py" + - "--ignore-glob=*test_all_optics.py" + + - pypi_name: mocpy + tier: affiliated + module: mocpy + repo_url: https://github.com/cds-astro/mocpy.git + install_extras: [dev] + + - pypi_name: naima + tier: affiliated + module: naima + repo_url: https://github.com/zblz/naima.git + + - pypi_name: omnifit + tier: affiliated + module: omnifit + repo_url: https://github.com/RiceMunk/omnifit.git + + - pypi_name: poliastro + tier: affiliated + module: poliastro + repo_url: https://github.com/poliastro/poliastro.git + + - pypi_name: pycbc + tier: affiliated + module: pycbc + repo_url: https://github.com/gwastro/pycbc.git + + - pypi_name: pydl + tier: affiliated + module: pydl + repo_url: https://github.com/weaverba137/pydl.git + install_extras: [test] + + - pypi_name: pyregion + tier: affiliated + module: pyregion + repo_url: https://github.com/astropy/pyregion.git + + - pypi_name: pyspeckit + tier: affiliated + module: pyspeckit + repo_url: https://github.com/pyspeckit/pyspeckit.git + + - pypi_name: pyvo + tier: affiliated + module: pyvo + repo_url: https://github.com/astropy/pyvo.git + install_extras: [test] + + - pypi_name: regularizepsf + tier: affiliated + module: regularizepsf + repo_url: https://github.com/punch-mission/regularizepsf.git + + - pypi_name: sbpy + tier: affiliated + module: sbpy + repo_url: https://github.com/NASA-Planetary-Science/sbpy.git + + - pypi_name: sncosmo + tier: affiliated + module: sncosmo + repo_url: https://github.com/sncosmo/sncosmo.git + + - pypi_name: spectral-cube + tier: affiliated + module: spectral_cube + repo_url: https://github.com/radio-astro-tools/spectral-cube.git + install_extras: [test] + + - pypi_name: spherical-geometry + tier: affiliated + module: spherical_geometry + repo_url: https://github.com/spacetelescope/spherical_geometry.git + + - pypi_name: statmorph + tier: affiliated + module: statmorph + repo_url: https://github.com/vrodgom/statmorph.git + + - pypi_name: synphot + tier: affiliated + module: synphot + repo_url: https://github.com/spacetelescope/synphot_refactor.git + + # --- Affiliated packages reviewed through the pyOpenSci process and + # cross-listed at https://www.pyopensci.org/python-packages.html + # (post-APE 22; not in the frozen registry.json above). + - pypi_name: astrodata + tier: pyopensci + module: astrodata + repo_url: https://github.com/GeminiDRSoftware/astrodata.git + + - pypi_name: astromartini + tier: pyopensci + # installed and imported as `martini`; `astromartini` is just the PyPI name. + module: martini + repo_url: https://github.com/kyleaoman/martini.git + install_extras: [testing] + + - pypi_name: autogalaxy + tier: pyopensci + module: autogalaxy + repo_url: https://github.com/PyAutoLabs/PyAutoGalaxy.git + install_extras: [test] + + - pypi_name: GALFITools + tier: pyopensci + module: galfitools # installed package name is lowercase + repo_url: https://github.com/canorve/GALFITools.git + install_extras: [testing] + + - pypi_name: petrofit + tier: pyopensci + module: petrofit + repo_url: https://github.com/PetroFit/petrofit.git + install_extras: [dev] + + - pypi_name: stingray + tier: pyopensci + module: stingray + repo_url: https://github.com/StingraySoftware/stingray.git + install_extras: [test] + + - pypi_name: zodipy + tier: pyopensci + module: zodipy + repo_url: https://github.com/Cosmoglobe/zodipy.git diff --git a/pyproject.toml b/pyproject.toml index 6ff410d..7dcfe9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,23 @@ -[tool.pytest.ini_options] -astropy_header = true -doctest_plus = "enabled" -text_file_format = "rst" -filterwarnings = [ - "ignore:numpy\\.ufunc size changed:RuntimeWarning", - "ignore:numpy\\.ndarray size changed:RuntimeWarning", +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "astropy-integration" +version = "0.1.0" +description = "Run and dashboard the astropy ecosystem integration matrix." +requires-python = ">=3.11" +dependencies = [ + "jinja2", + "packaging", + "pyyaml", ] + +[project.scripts] +astropy-integration = "astropy_integration.cli:main" + +[tool.setuptools.packages.find] +include = ["astropy_integration*"] + +[tool.setuptools.package-data] +astropy_integration = ["templates/*.html", "templates/*.css"] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index a1b0f65..0000000 --- a/tox.ini +++ /dev/null @@ -1,69 +0,0 @@ -[tox] -# We define an environment per package so that we can then run 'tox' on its own -# and have all packages be tested even if some have failures. -envlist = - # Check compatibility of Coordinated Packages. Additionally: - # sunpy - # These are widely used packages that have historically - # had issues with some astropy releases, so we include them here - # as regression testing. - py{311,312,313,314}-{all,asdf_astropy,astropy_healpix,astroquery,ccdproc,photutils,regions,reproject,specreduce,specutils,sunpy}{,-dev} - -[testenv] -# Pass through the following environment variables which are needed for the CI -passenv = HOME,CI - -pip_pre = true - -# Note that we install all dependencies in all environments to catch any -# side effects and make sure all test suites pass with all packages -deps = - astropy[all,test] - - asdf_astropy-!dev,all-!dev: asdf_astropy[test] - asdf_astropy-dev,all-dev: asdf_astropy[test] @ git+https://github.com/astropy/asdf-astropy.git - - astropy_healpix-!dev,all-!dev: astropy_healpix[test] - astropy_healpix-dev,all-dev: astropy_healpix[test] @ git+https://github.com/astropy/astropy-healpix.git - - # https://github.com/astropy/astropy-integration-testing/issues/30 - #astroquery-!dev,all-!dev: astroquery[test,all] - #astroquery-dev,all-dev: astroquery[test,all] @ git+https://github.com/astropy/astroquery.git - - ccdproc,all: psutil - ccdproc-!dev,all-!dev: ccdproc[test,all] - ccdproc-dev,all-dev: ccdproc[test,all] @ git+https://github.com/astropy/ccdproc.git - - photutils-!dev,all-!dev: photutils[test,all] - photutils-dev,all-dev: photutils[test,all] @ git+https://github.com/astropy/photutils.git - - regions-!dev,all-!dev: regions[test,all] - regions-dev,all-dev: regions[test,all] @ git+https://github.com/astropy/regions.git - - reproject,all: gwcs - reproject-!dev,all-!dev: reproject[test,all] - reproject-dev,all-dev: reproject[test,all] @ git+https://github.com/astropy/reproject.git - - specreduce-!dev,all-!dev: specreduce[test] - specreduce-dev,all-dev: specreduce[test] @ git+https://github.com/astropy/specreduce.git - - specutils-!dev,all-!dev: specutils[all,test] - specutils-dev,all-dev: specutils[all,test] @ git+https://github.com/astropy/specutils.git - - sunpy-!dev,all-!dev: sunpy[tests,all] - sunpy-dev,all-dev: sunpy[tests,all] @ git+https://github.com/sunpy/sunpy.git - -skip_install = true - -commands = - {list_dependencies_command} - asdf_astropy,all: pytest --pyargs asdf_astropy - astropy_healpix,all: pytest --pyargs astropy_healpix - astroquery,all: pytest --pyargs astroquery -k "not test_deprecated_namespace_import_warning and not test_raises_deprecation_warning" - ccdproc,all: pytest --pyargs ccdproc - photutils,all: pytest --pyargs photutils - regions,all: pytest --pyargs regions - reproject,all: pytest --pyargs reproject --ignore reproject/spherical_intersect/setup_package.py - specreduce,all: pytest --pyargs specreduce - specutils,all: pytest --pyargs specutils - sunpy,all: pytest --pyargs sunpy -c sunpy_pytest.ini