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
63 changes: 56 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
# =============================================================================
# .github/workflows/ci.yml — Continuous Integration
# =============================================================================
# Runs on pull requests to main. Fast feedback on tests + lint.
# Pull requests to main: fast required gate (tests minus @pytest.mark.slow) + lint.
# The full suite (including slow tests) runs nightly on a schedule and on demand
# via workflow_dispatch. Dependencies are cached via setup-uv to avoid
# re-downloading torch & friends on every run.
# =============================================================================

name: CI

on:
pull_request:
branches: [main]
workflow_dispatch:
schedule:
# Nightly full suite at 03:17 UTC (off-peak; avoids the top-of-hour cron herd).
- cron: "17 3 * * *"

permissions: read-all

concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
# ── Fast required gate: everything except @pytest.mark.slow ────────────────
test:
name: Tests
name: Tests (fast)
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
permissions:
contents: read

Expand All @@ -31,16 +40,51 @@ jobs:
python-version: "3.13"

- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
enable-cache: true
cache-dependency-glob: |
uv.lock
pyproject.toml

- name: Install dependencies
run: uv pip install --system -e ".[dev]"
run: uv sync --extra dev

- name: Run pytest (fast — excludes @pytest.mark.slow)
run: uv run pytest tests/ -m "not slow" -n auto --tb=short

# ── Full suite: nightly schedule + manual dispatch ─────────────────────────
test-full:
name: Tests (full suite)
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
permissions:
contents: read

- name: Run pytest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.13"

- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
enable-cache: true
cache-dependency-glob: |
uv.lock
pyproject.toml

- name: Install dependencies
run: uv sync --extra dev

- name: Run pytest (full suite, including slow)
run: uv run pytest tests/ -v -n auto --tb=short

# ── Lint ───────────────────────────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
permissions:
contents: read

Expand All @@ -52,10 +96,15 @@ jobs:
python-version: "3.13"

- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
enable-cache: true
cache-dependency-glob: |
uv.lock
pyproject.toml

- name: Install dependencies
run: uv pip install --system -e ".[dev]"
run: uv sync --extra dev

- name: Ruff
run: ruff check src/ tests/
run: uv run ruff check src/ tests/
continue-on-error: true
13 changes: 10 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,20 @@ jobs:
python-version: "3.13"

- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
enable-cache: true
cache-dependency-glob: |
uv.lock
pyproject.toml

- name: Install dependencies
run: uv pip install --system -e ".[dev,docs]"
run: uv sync --extra dev --extra docs

# ── Tests ─────────────────────────────────────────────────────────
- name: Run tests
run: uv run pytest tests/ -v -n auto --tb=short
# Fast gate only: the full suite (incl. slow) already ran on the PR's
# required check and nightly. This is a quick pre-publish sanity check.
- name: Run tests (fast — excludes slow)
run: uv run pytest tests/ -m "not slow" -n auto --tb=short

# ── Semantic Release ──────────────────────────────────────────────
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
Expand Down
42 changes: 42 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Pre-commit / pre-push hooks for spotoptim.
#
# Install once per clone (hooks live in .git/ and are never pushed):
# uv run pre-commit install --hook-type pre-push --hook-type pre-commit
#
# - on `git push` : runs the FAST test suite and blocks the push if it fails
# - on `git commit` : runs ruff + black --check on changed Python files
#
# Emergency bypass (skips all hooks): git push --no-verify
#
# Requires pre-commit >= 3.2.0 (stage was renamed `push` -> `pre-push`).
minimum_pre_commit_version: "3.2.0"
default_stages: [pre-commit]

repos:
- repo: local
hooks:
# Core gate: block `git push` when the fast suite fails.
- id: pytest-fast
name: pytest (fast, excludes slow)
entry: uv run pytest -m "not slow" -n auto
language: system
pass_filenames: false
always_run: true
stages: [pre-push]

# Lightweight, non-mutating commit-time checks (project-pinned tools via uv).
- id: ruff
name: ruff check
entry: uv run ruff check
language: system
types_or: [python, pyi]
require_serial: true
stages: [pre-commit]

- id: black
name: black --check
entry: uv run black --check
language: system
types_or: [python, pyi]
require_serial: true
stages: [pre-commit]
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,16 +238,42 @@ See docs/examples.md for more details and additional examples.
git clone https://github.com/sequential-parameter-optimization/spotoptim.git
cd spotoptim

# Install with uv
uv pip install -e .
# Install with uv (editable, with dev dependencies)
uv sync --extra dev

# Run tests
uv run pytest tests/
# Run the fast suite (excludes slow end-to-end tests) — what CI/PRs gate on
uv run pytest tests/ -m "not slow" -n auto

# Run the full suite (everything, including slow tests)
uv run pytest tests/ -n auto

# Build package
uv build
```

### Fast vs. full tests

Heavy end-to-end optimization tests are tagged `@pytest.mark.slow` (registered
centrally in `tests/conftest.py`). The fast path skips them with `-m "not slow"`;
the full suite and the nightly CI job run everything.

### Git hooks (pre-push test gate)

A `.pre-commit-config.yaml` runs the **fast** test suite before every `git push`
and **blocks the push if any test fails**. Hooks are per-clone and are *not*
installed automatically — install them once after cloning:

```bash
uv run pre-commit install --hook-type pre-push --hook-type pre-commit
```

- on `git push` → `uv run pytest -m "not slow" -n auto`
- on `git commit` → `ruff check` + `black --check` on changed files

Emergency bypass (use sparingly): `git push --no-verify`. Note that the bare
`pre-commit install` only wires the commit stage — the `--hook-type pre-push`
flag is required to enable the test gate.

## Release Troubleshooting

If a release fails (for example with `semantic-release` push/tag permission errors), use this checklist.
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,21 @@ dev = [
"ruff>=0.3.0",
"safety>=3.0.0",
"bandit>=1.8.0",
"pre-commit>=3.7.0",
]

[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-xdist>=3.5.0",
"pytest-timeout>=2.3.0",
"black>=24.1.0",
"isort>=5.13.0",
"ruff>=0.3.0",
"safety>=3.0.0",
"bandit>=1.8.0",
"pre-commit>=3.7.0",
]
docs = [
# Quarto API documentation generator (replaces mkdocstrings)
Expand All @@ -79,6 +82,11 @@ testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
# Keep slow-test timings visible on every run; the registry lives in tests/conftest.py.
addopts = ["--durations=15"]
markers = [
"slow: heavy end-to-end test; deselect with -m \"not slow\". Runs in nightly/full CI.",
]
filterwarnings = [
"ignore::UserWarning:matplotlib",
]
Expand Down
85 changes: 85 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Shared pytest configuration for the spotoptim test suite.

Slow-test registry
------------------
The suite contains ~1.7k tests; a few dozen end-to-end optimization runs
dominate the wall-clock. Those are tagged ``@pytest.mark.slow`` here, in one
central place, so that:

* the fast path (PR gate, pre-push hook) runs ``pytest -m "not slow"`` and
skips them, and
* the nightly / full CI job runs everything.

Entries in :data:`SLOW_NODEIDS` are matched against each test's node id with
the parametrization suffix (``[...]``) stripped. An entry may be:

* a file ``tests/test_x0_starting_point.py``
* a class ``tests/test_batch_eval.py::TestBatchEvalEndToEnd``
* a single test ``tests/test_save_load.py::TestEdgeCases::test_reproducibility_after_load``

This list was derived from ``pytest --durations`` (tests taking >~8 s).
To refresh it, run ``uv run pytest tests/ -n auto --durations=50`` and update
the entries below. Stale entries that no longer match anything are harmless.
New slow tests may instead be decorated directly with ``@pytest.mark.slow``.
"""

import pytest

# Heaviest tests (>~8 s each), grouped by file. Class/file entries also cover
# their faster sibling integration tests, which belong in the full suite too.
SLOW_NODEIDS = {
# --- whole files (every test is a full optimization run) ---
"tests/test_x0_starting_point.py",
# --- integration test classes ---
"tests/test_batch_eval.py::TestBatchEvalEndToEnd",
"tests/test_termination_criteria.py::TestTerminationCriteria",
"tests/test_thread_pool_search.py::TestHybridExecutorEndToEnd",
"tests/test_no_gil_awareness.py::TestSimulatedNoGilPath",
"tests/test_no_gil_awareness.py::TestGilBuildEndToEnd",
"tests/test_multiobjective.py::TestMultiObjectiveOptimization",
"tests/test_transformations.py::TestTransformationOptimization",
"tests/test_reproducibility_comprehensive.py::TestSpotOptimReproducibility",
"tests/test_optimize_refactored_methods.py::TestOptimizeIntegration",
# --- individual heavy tests ---
"tests/test_early_stopping.py::test_max_restarts_does_not_trigger_with_improvement",
"tests/test_parallel_optimization.py::TestParallelOptimization::test_parallel_execution_basic",
"tests/test_multiobjective.py::TestMultiObjectiveEdgeCases::test_many_objectives",
"tests/test_initial_design_nan_handling.py::test_initial_design_with_mixed_nan_inf",
"tests/test_cookbook_examples.py::test_example_4_nelder_mead",
"tests/test_spotoptim_mlp_surrogate.py::test_mlp_surrogate_uncertainty_in_loop",
"tests/test_save_load.py::TestEdgeCases::test_reproducibility_after_load",
"tests/test_run_sequential_loop_example.py::test_run_sequential_loop_example",
"tests/test_factor_variables.py::TestFactorVariables::test_many_factor_levels",
"tests/test_spotoptim_deep.py::TestConvergenceQuality::test_sphere_2d_converges_near_origin",
"tests/test_spotoptim_deep.py::TestSeedReproducibility::test_same_seed_same_results",
"tests/test_max_iter_validation.py::TestMaxIterValidation::test_max_iter_greater_than_n_initial_works",
"tests/test_termination.py::TestBackwardCompatibility::test_old_behavior_without_max_time",
"tests/test_termination.py::TestMaxIterTermination::test_max_iter_with_custom_initial_design",
"tests/test_tolerance_x.py::TestToleranceXFloatVariables::test_no_duplicate_evaluations_float",
"tests/test_tolerance_x.py::TestToleranceXFactorVariables::test_no_duplicate_evaluations_factors",
"tests/test_spotoptim.py::TestSpotOptimOptimize::test_optimize_with_seed_reproducibility",
"tests/test_parallel_reporting.py::test_parallel_reporting",
"tests/test_parallel_merging.py::test_parallel_merging",
"tests/test_deterministic.py::TestDeterministicBehavior::test_deterministic_with_provided_initial_design",
"tests/test_acquisition_failure.py::TestAcquisitionFailureWithVariableTypes::test_acquisition_failure_with_mixed_variables",
"tests/test_spot_optim_args.py::test_kwargs_only",
"tests/test_tensorboard.py::TestTensorBoardIntegration::test_tensorboard_with_custom_var_names",
"tests/test_tensorboard_clean.py::TestTensorBoardClean::test_clean_with_optimization_run",
}


def _is_slow(nodeid: str) -> bool:
"""Return True if ``nodeid`` is covered by a SLOW_NODEIDS entry."""
base = nodeid.split("[", 1)[0] # drop parametrization suffix
return any(base == entry or base.startswith(entry + "::") for entry in SLOW_NODEIDS)


def pytest_collection_modifyitems(config, items):
"""Tag registered heavy tests with the ``slow`` marker during collection.

Runs before ``-m`` deselection, so ``pytest -m "not slow"`` skips them.
"""
slow = pytest.mark.slow
for item in items:
if _is_slow(item.nodeid):
item.add_marker(slow)
Loading
Loading