|
| 1 | +name: Campaign matrix |
| 2 | + |
| 3 | +# Temporary test-only workflow: runs the fast test suite across operating |
| 4 | +# systems and Python versions, builds the FreshCore extension on every OS, and |
| 5 | +# checks each optional extra on Linux. It publishes nothing and writes nothing. |
| 6 | + |
| 7 | +on: |
| 8 | + push: |
| 9 | + branches: [campaign/matrix] |
| 10 | + workflow_dispatch: |
| 11 | + |
| 12 | +concurrency: |
| 13 | + group: campaign-matrix-${{ github.sha }} |
| 14 | + |
| 15 | +permissions: |
| 16 | + contents: read |
| 17 | + |
| 18 | +defaults: |
| 19 | + run: |
| 20 | + shell: bash |
| 21 | + |
| 22 | +jobs: |
| 23 | + suite: |
| 24 | + name: suite (${{ matrix.os }}, py${{ matrix.python }}) |
| 25 | + runs-on: ${{ matrix.os }} |
| 26 | + timeout-minutes: 90 |
| 27 | + strategy: |
| 28 | + fail-fast: false |
| 29 | + matrix: |
| 30 | + os: [ubuntu-latest, macos-latest, windows-latest] |
| 31 | + python: ["3.9", "3.12", "3.13", "3.14"] |
| 32 | + include: |
| 33 | + - python: "3.9" |
| 34 | + pandas: "pandas>=1.5,<2" |
| 35 | + numpy: "numpy<2" |
| 36 | + env: |
| 37 | + PYTHONUTF8: "1" |
| 38 | + steps: |
| 39 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 40 | + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 |
| 41 | + with: |
| 42 | + python-version: ${{ matrix.python }} |
| 43 | + allow-prereleases: true |
| 44 | + cache: pip |
| 45 | + - name: Install |
| 46 | + run: | |
| 47 | + python -m pip install --upgrade pip |
| 48 | + if pip install -e ".[dev,ml]" pytest-timeout; then |
| 49 | + echo "EXTRA_INSTALLED=dev,ml" |
| 50 | + else |
| 51 | + echo "EXTRA_FALLBACK=dev" |
| 52 | + echo "::warning::.[dev,ml] failed to install; falling back to .[dev]" |
| 53 | + pip install -e ".[dev]" pytest-timeout |
| 54 | + fi |
| 55 | + if [ -n "${{ matrix.pandas }}" ]; then pip install "${{ matrix.pandas }}"; fi |
| 56 | + if [ -n "${{ matrix.numpy }}" ]; then pip install "${{ matrix.numpy }}"; fi |
| 57 | + python -c "import sys, platform, pandas, numpy; print('ENV', sys.version, platform.platform(), 'pandas', pandas.__version__, 'numpy', numpy.__version__)" |
| 58 | + pip freeze > pip-freeze.txt |
| 59 | + - name: Test (fast marker set) |
| 60 | + run: | |
| 61 | + python -m pytest -m "not online and not large" \ |
| 62 | + -o addopts="--strict-markers -q -rfE" -p no:cacheprovider \ |
| 63 | + --timeout 600 --junitxml=junit.xml |
| 64 | + - name: Upload results |
| 65 | + if: always() |
| 66 | + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
| 67 | + with: |
| 68 | + name: junit-suite-${{ matrix.os }}-py${{ matrix.python }} |
| 69 | + path: | |
| 70 | + junit.xml |
| 71 | + pip-freeze.txt |
| 72 | + if-no-files-found: warn |
| 73 | + |
| 74 | + freshcore-native: |
| 75 | + name: freshcore-native (${{ matrix.os }}) |
| 76 | + runs-on: ${{ matrix.os }} |
| 77 | + timeout-minutes: 60 |
| 78 | + strategy: |
| 79 | + fail-fast: false |
| 80 | + matrix: |
| 81 | + os: [ubuntu-latest, macos-latest, windows-latest] |
| 82 | + env: |
| 83 | + RUSTUP_TOOLCHAIN: "1.98.1" |
| 84 | + PYTHONUTF8: "1" |
| 85 | + steps: |
| 86 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 87 | + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 |
| 88 | + with: |
| 89 | + python-version: "3.12" |
| 90 | + cache: pip |
| 91 | + - name: Install Rust toolchain |
| 92 | + run: | |
| 93 | + rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal |
| 94 | + rustc --version |
| 95 | + cargo --version |
| 96 | + - name: Install |
| 97 | + # maturin develop installs into a virtualenv, so create one in the |
| 98 | + # workspace and put it first on PATH for the remaining steps. |
| 99 | + run: | |
| 100 | + python -m venv .venv |
| 101 | + if [ "$RUNNER_OS" = "Windows" ]; then |
| 102 | + VENV_DIR="$(cygpath -w "$PWD/.venv")" |
| 103 | + VENV_BIN="$(cygpath -w "$PWD/.venv/Scripts")" |
| 104 | + VPY=".venv/Scripts/python.exe" |
| 105 | + else |
| 106 | + VENV_DIR="$PWD/.venv" |
| 107 | + VENV_BIN="$PWD/.venv/bin" |
| 108 | + VPY=".venv/bin/python" |
| 109 | + fi |
| 110 | + echo "VIRTUAL_ENV=$VENV_DIR" >> "$GITHUB_ENV" |
| 111 | + echo "$VENV_BIN" >> "$GITHUB_PATH" |
| 112 | + "$VPY" -m pip install --upgrade pip |
| 113 | + "$VPY" -m pip install -e ".[dev,freshcore]" |
| 114 | + - name: Show interpreter |
| 115 | + run: | |
| 116 | + which python |
| 117 | + python -c "import sys; print(sys.executable, sys.prefix)" |
| 118 | + - name: Rust unit tests |
| 119 | + run: cargo test --locked --manifest-path crates/freshcore/Cargo.toml |
| 120 | + - name: Build the extension |
| 121 | + run: maturin develop --manifest-path crates/freshcore/Cargo.toml --features extension-module |
| 122 | + - name: FreshCore tests |
| 123 | + run: | |
| 124 | + python -c "import freshdata_freshcore; print(freshdata_freshcore.__file__)" |
| 125 | + python -m pytest tests/test_execution -k freshcore -o addopts="--strict-markers" -q \ |
| 126 | + -p no:cacheprovider --junitxml=junit.xml -rs |
| 127 | + - name: Upload results |
| 128 | + if: always() |
| 129 | + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
| 130 | + with: |
| 131 | + name: junit-freshcore-${{ matrix.os }} |
| 132 | + path: junit.xml |
| 133 | + if-no-files-found: warn |
| 134 | + |
| 135 | + extras-linux: |
| 136 | + name: extras-linux (${{ matrix.extra }}) |
| 137 | + runs-on: ubuntu-latest |
| 138 | + timeout-minutes: 90 |
| 139 | + strategy: |
| 140 | + fail-fast: false |
| 141 | + matrix: |
| 142 | + extra: [spark, airflow, dagster, dbt, kafka, flight, semantic, privacy, cleanlab, viz, notebook, domains, excel, entity-resolution, outofcore, all] |
| 143 | + env: |
| 144 | + EXTRA: ${{ matrix.extra }} |
| 145 | + steps: |
| 146 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 147 | + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 |
| 148 | + with: |
| 149 | + python-version: "3.12" |
| 150 | + cache: pip |
| 151 | + - name: Set up Java (spark) |
| 152 | + if: matrix.extra == 'spark' || matrix.extra == 'all' |
| 153 | + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 |
| 154 | + with: |
| 155 | + distribution: temurin |
| 156 | + java-version: "17" |
| 157 | + - name: Resolve extra plan |
| 158 | + run: | |
| 159 | + case "$EXTRA" in |
| 160 | + spark) |
| 161 | + MODS="pyspark" |
| 162 | + FD="from freshdata.execution._lazy import has_pyspark, require_pyspark; assert has_pyspark(); require_pyspark(); import freshdata.execution.backends._spark" |
| 163 | + TESTS="tests/test_execution/test_spark_engine.py tests/test_execution/test_spark_engine_fixes.py" ;; |
| 164 | + airflow) |
| 165 | + MODS="airflow" |
| 166 | + FD="from freshdata.integrations.airflow import FreshDataCleanOperator; print(FreshDataCleanOperator.__mro__)" |
| 167 | + TESTS="tests/test_integrations/test_airflow.py tests/test_integrations/test_imports.py" ;; |
| 168 | + dagster) |
| 169 | + MODS="dagster" |
| 170 | + FD="from freshdata.integrations.dagster import FreshDataResource, freshdata_asset_check; print(FreshDataResource)" |
| 171 | + TESTS="tests/test_integrations/test_dagster.py tests/test_integrations/test_imports.py" ;; |
| 172 | + dbt) |
| 173 | + MODS="dbt.version sqlalchemy" |
| 174 | + FD="from freshdata.integrations.dbt import FreshDataDbtTransform; print(FreshDataDbtTransform)" |
| 175 | + TESTS="tests/test_integrations/test_dbt.py tests/test_integrations/test_dbt_tests_exporter.py tests/test_integrations/test_dbt_tests_exporter_yaml.py tests/test_integrations/test_dbt_transform_fail_audit.py tests/test_integrations/test_core.py" ;; |
| 176 | + kafka) |
| 177 | + MODS="kafka" |
| 178 | + FD="from freshdata.streaming._connectors import kafka_batches; print(kafka_batches)" |
| 179 | + TESTS="tests/test_streaming_cleaner.py tests/test_streaming_reports.py tests/test_streaming_cli.py" ;; |
| 180 | + flight) |
| 181 | + MODS="pyarrow.flight" |
| 182 | + FD="from freshdata.streaming._connectors import flight_batches; print(flight_batches)" |
| 183 | + TESTS="tests/test_streaming_cleaner.py tests/test_streaming_reports.py tests/test_streaming_cli.py" ;; |
| 184 | + semantic) |
| 185 | + MODS="onnxruntime tokenizers" |
| 186 | + FD="from freshdata.models._lazy import has_semantic_extra; assert has_semantic_extra()" |
| 187 | + TESTS="tests/test_semantic_extra_optional.py tests/test_models_runtime.py tests/test_models_download_robustness.py tests/test_models_registry.py tests/test_semantic_backends.py tests/test_cli_models.py" ;; |
| 188 | + privacy) |
| 189 | + MODS="presidio_analyzer presidio_anonymizer pyffx" |
| 190 | + FD="import freshdata.enterprise.privacy as p; print(p.__name__)" |
| 191 | + TESTS="tests/test_enterprise_privacy.py tests/test_privacy_adversarial.py tests/test_privacy_keyless_defaults.py tests/test_privacy_missing_and_labels.py tests/test_privacy_policy.py tests/test_privacy_policy_classification.py" ;; |
| 192 | + cleanlab) |
| 193 | + MODS="cleanlab sklearn" |
| 194 | + FD="import freshdata.enterprise.cleaner as c; print(c.__name__)" |
| 195 | + TESTS="tests/test_enterprise_cleaner.py" ;; |
| 196 | + viz) |
| 197 | + MODS="itables plotly great_tables" |
| 198 | + FD="from freshdata.render import require; [require(p) for p in ('itables', 'plotly', 'great_tables')]" |
| 199 | + TESTS="tests/test_render_html.py tests/test_peel_view.py tests/test_peel_notebook.py tests/test_report_rendering_fixes.py" ;; |
| 200 | + notebook) |
| 201 | + MODS="itables plotly great_tables anywidget IPython" |
| 202 | + FD="from freshdata.render import require; [require(p) for p in ('itables', 'plotly', 'great_tables', 'anywidget', 'IPython')]; import freshdata.render.notebook" |
| 203 | + TESTS="tests/test_render_html.py tests/test_peel_view.py tests/test_peel_notebook.py tests/test_report_rendering_fixes.py" ;; |
| 204 | + domains) |
| 205 | + MODS="yaml" |
| 206 | + FD="import freshdata.domains as d; print(d.__name__)" |
| 207 | + TESTS="tests/domains tests/test_domain_rules_transport_icpn.py" ;; |
| 208 | + excel) |
| 209 | + MODS="openpyxl" |
| 210 | + FD="import freshdata as fd; assert callable(fd.clean_excel)" |
| 211 | + TESTS="tests/test_clean_excel.py tests/test_csv_formula_sanitize.py" ;; |
| 212 | + entity-resolution) |
| 213 | + MODS="duckdb" |
| 214 | + FD="import freshdata.enterprise.entity_resolution as er; print(er.__name__)" |
| 215 | + TESTS="tests/test_enterprise_entity_resolution.py tests/test_enterprise_entity_resolution_dedupe.py tests/test_link.py tests/test_er_comparators.py tests/test_er_review_loop.py" ;; |
| 216 | + outofcore) |
| 217 | + MODS="polars duckdb pyarrow" |
| 218 | + FD="from freshdata.execution._lazy import require_polars, require_duckdb, require_pyarrow; require_polars(); require_duckdb(); require_pyarrow()" |
| 219 | + TESTS="tests/test_execution tests/test_polars_adapter.py" ;; |
| 220 | + all) |
| 221 | + MODS="polars pyarrow sklearn yaml cleanlab duckdb presidio_analyzer presidio_anonymizer pyffx itables plotly great_tables anywidget IPython openpyxl" |
| 222 | + FD="import freshdata.enterprise.privacy, freshdata.enterprise.cleaner, freshdata.render.notebook, freshdata.domains" |
| 223 | + TESTS="tests" ;; |
| 224 | + *) echo "unknown extra $EXTRA"; exit 1 ;; |
| 225 | + esac |
| 226 | + { |
| 227 | + echo "MODS=$MODS" |
| 228 | + echo "FD_SMOKE=$FD" |
| 229 | + echo "TESTS=$TESTS" |
| 230 | + } >> "$GITHUB_ENV" |
| 231 | + - name: Extra-only install (no dev) and import smoke |
| 232 | + # Checks the extra is self-sufficient: the dev extra already ships some |
| 233 | + # of these packages, which would hide a missing dependency. |
| 234 | + continue-on-error: true |
| 235 | + run: | |
| 236 | + python -m venv /tmp/xonly |
| 237 | + /tmp/xonly/bin/python -m pip install --upgrade pip |
| 238 | + /tmp/xonly/bin/pip install -e ".[$EXTRA]" |
| 239 | + /tmp/xonly/bin/python - <<'PY' |
| 240 | + import importlib, os, sys |
| 241 | + bad = [] |
| 242 | + for m in os.environ["MODS"].split(): |
| 243 | + try: |
| 244 | + importlib.import_module(m) |
| 245 | + print("EXTRA_ONLY_IMPORT_OK", m) |
| 246 | + except Exception as exc: |
| 247 | + print("EXTRA_ONLY_IMPORT_FAIL", m, type(exc).__name__, exc) |
| 248 | + bad.append(m) |
| 249 | + try: |
| 250 | + exec(os.environ["FD_SMOKE"]) |
| 251 | + print("EXTRA_ONLY_FD_SMOKE_OK") |
| 252 | + except Exception as exc: |
| 253 | + print("EXTRA_ONLY_FD_SMOKE_FAIL", type(exc).__name__, exc) |
| 254 | + bad.append("freshdata-smoke") |
| 255 | + sys.exit(1 if bad else 0) |
| 256 | + PY |
| 257 | + - name: Install (dev + extra) |
| 258 | + run: | |
| 259 | + python -m pip install --upgrade pip |
| 260 | + if ! pip install -e ".[dev,$EXTRA]" pytest-timeout; then |
| 261 | + echo "EXTRA_INSTALL_FAIL=$EXTRA" |
| 262 | + echo "::error::pip install -e .[dev,$EXTRA] failed" |
| 263 | + exit 1 |
| 264 | + fi |
| 265 | + echo "EXTRA_INSTALL_OK=$EXTRA" |
| 266 | + pip freeze > pip-freeze.txt |
| 267 | + - name: Import smoke |
| 268 | + run: | |
| 269 | + python - <<'PY' |
| 270 | + import importlib, os, sys |
| 271 | + bad = [] |
| 272 | + for m in os.environ["MODS"].split(): |
| 273 | + try: |
| 274 | + mod = importlib.import_module(m) |
| 275 | + print("IMPORT_OK", m, getattr(mod, "__version__", "")) |
| 276 | + except Exception as exc: |
| 277 | + print("IMPORT_FAIL", m, type(exc).__name__, exc) |
| 278 | + bad.append(m) |
| 279 | + try: |
| 280 | + exec(os.environ["FD_SMOKE"]) |
| 281 | + print("FD_SMOKE_OK") |
| 282 | + except Exception as exc: |
| 283 | + print("FD_SMOKE_FAIL", type(exc).__name__, exc) |
| 284 | + bad.append("freshdata-smoke") |
| 285 | + sys.exit(1 if bad else 0) |
| 286 | + PY |
| 287 | + - name: Related tests |
| 288 | + run: | |
| 289 | + # shellcheck disable=SC2086 |
| 290 | + python -m pytest $TESTS -m "not online and not large" \ |
| 291 | + -o addopts="--strict-markers -q -rs" -p no:cacheprovider \ |
| 292 | + --timeout 600 --junitxml=junit.xml |
| 293 | + - name: Count skips for missing modules |
| 294 | + if: always() |
| 295 | + run: | |
| 296 | + python - <<'PY' |
| 297 | + import os, re, xml.etree.ElementTree as ET |
| 298 | + if not os.path.exists("junit.xml"): |
| 299 | + print("NO_JUNIT"); raise SystemExit(0) |
| 300 | + root = ET.parse("junit.xml").getroot() |
| 301 | + pat = re.compile(r"No module named|could not import|not installed|unavailable|requires? .{0,40}install", re.I) |
| 302 | + total = passed = failed = skipped = missing = 0 |
| 303 | + reasons = {} |
| 304 | + for tc in root.iter("testcase"): |
| 305 | + total += 1 |
| 306 | + kids = {c.tag: c for c in tc} |
| 307 | + if "failure" in kids or "error" in kids: |
| 308 | + failed += 1 |
| 309 | + elif "skipped" in kids: |
| 310 | + skipped += 1 |
| 311 | + msg = kids["skipped"].get("message", "") or (kids["skipped"].text or "") |
| 312 | + if pat.search(msg): |
| 313 | + missing += 1 |
| 314 | + reasons[msg[:160]] = reasons.get(msg[:160], 0) + 1 |
| 315 | + else: |
| 316 | + passed += 1 |
| 317 | + line = (f"EXTRA_RESULT extra={os.environ['EXTRA']} total={total} passed={passed} " |
| 318 | + f"failed={failed} skipped={skipped} skipped_missing_module={missing}") |
| 319 | + print(line) |
| 320 | + for r, n in sorted(reasons.items(), key=lambda kv: -kv[1]): |
| 321 | + print(f"SKIP_MISSING [{n}] {r}") |
| 322 | + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as fh: |
| 323 | + fh.write(line + "\n") |
| 324 | + PY |
| 325 | + - name: Upload results |
| 326 | + if: always() |
| 327 | + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
| 328 | + with: |
| 329 | + name: junit-extra-${{ matrix.extra }} |
| 330 | + path: | |
| 331 | + junit.xml |
| 332 | + pip-freeze.txt |
| 333 | + if-no-files-found: warn |
0 commit comments