From e0903e3b91d2aad4e830cf2673f5f0418f917df6 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 21 Feb 2026 13:27:25 +0100 Subject: [PATCH 1/7] Initial rework --- .github/workflows/{main.yml => test.yml} | 211 +++++++++++------- .github/workflows/{release.yml => wheels.yml} | 71 +++--- 2 files changed, 165 insertions(+), 117 deletions(-) rename .github/workflows/{main.yml => test.yml} (50%) rename .github/workflows/{release.yml => wheels.yml} (53%) diff --git a/.github/workflows/main.yml b/.github/workflows/test.yml similarity index 50% rename from .github/workflows/main.yml rename to .github/workflows/test.yml index 6cc45e03..2885e00c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/test.yml @@ -1,13 +1,12 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: pytest +name: Install and test on: push: - branches: ["main"] + branches: + - main pull_request: - branches: ["main"] + branches: + - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -15,17 +14,26 @@ concurrency: jobs: code-quality: + name: code-quality runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 + - name: repository checkout step + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - - uses: actions/setup-python@v6 + - name: python environment step + uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: "3.14" - name: install pre-commit - run: python3 -m pip install pre-commit + run: uv pip install pre-commit + env: + UV_SYSTEM_PYTHON: 1 - name: Checkout code uses: actions/checkout@v6 @@ -40,7 +48,7 @@ jobs: - name: Print changed files run: | - echo "Changed files: $CHANGED_FILES" + echo "Changed files:" && echo "$CHANGED_FILES" | tr ' ' '\n' - name: Run pre-commit on changed files run: | @@ -50,17 +58,89 @@ jobs: echo "No changed files to check." fi - pytest-nosoftdeps: + detect-notebooks-change: + needs: code-quality + name: detect change affecting notebooks + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + notebooks: ${{ steps.check.outputs.notebooks }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fetch main branch + run: git fetch origin main + + - name: Check if cookbook, pypfopt or pyproject.toml changed + id: check + run: | + if git diff --quiet origin/main -- cookbook/ pypfopt/ pyproject.toml; then + echo "No notebook related changes" + echo "notebooks=false" >> $GITHUB_OUTPUT + else + echo "Detected changes in notebooks or pypfopt" + echo "notebooks=true" >> $GITHUB_OUTPUT + fi + + run-notebook-examples: + needs: detect-notebooks-change + if: ${{ needs.detect-notebooks-change.outputs.notebooks == 'true' }} + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + fail-fast: false + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Display Python version + run: python -c "import sys; print(sys.version)" + + - name: Install dependencies + shell: bash + run: uv pip install .[dev,all_extras,notebook_test] --no-cache-dir + env: + UV_SYSTEM_PYTHON: 1 + + - name: Show dependencies + run: uv pip list + + - name: Collect notebooks + id: notebooks + shell: bash + run: | + NOTEBOOKS=$(find cookbook -name '*.ipynb' -print0 | xargs -0 echo) + echo "notebooks=$NOTEBOOKS" >> $GITHUB_OUTPUT + + - name: Run notebooks + shell: bash + run: | + uv run pytest --reruns 3 --nbmake --nbmake-timeout=3600 -vv ${{ steps.notebooks.outputs.notebooks }} + + test-nosoftdeps: needs: code-quality - name: nosoftdeps (${{ matrix.python-version }}, ${{ matrix.os }}) + name: test-nosoftdeps (${{ matrix.python-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} - env: - MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - fail-fast: false # to not fail all combinations if just one fails steps: - uses: actions/checkout@v6 @@ -78,30 +158,32 @@ jobs: - name: Display Python version run: python -c "import sys; print(sys.version)" + - name: Force non-GUI Matplotlib backend (Windows) + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: echo "MPLBACKEND=Agg" >> $env:GITHUB_ENV + - name: Install dependencies shell: bash - run: uv pip install ".[dev]" --no-cache-dir + run: uv pip install .[dev] --no-cache-dir env: UV_SYSTEM_PYTHON: 1 - name: Show dependencies run: uv pip list - - name: Test with pytest - run: | - pytest ./tests + - name: Run tests + run: pytest ./tests - pytest: - needs: pytest-nosoftdeps - name: (${{ matrix.python-version }}, ${{ matrix.os }}) + test-full: + needs: test-nosoftdeps + name: test-full (${{ matrix.python-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} - env: - MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - fail-fast: false # to not fail all combinations if just one fails + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v6 @@ -119,25 +201,27 @@ jobs: - name: Display Python version run: python -c "import sys; print(sys.version)" + - name: Force non-GUI Matplotlib backend (Windows) + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: echo "MPLBACKEND=Agg" >> $env:GITHUB_ENV + - name: Install dependencies shell: bash - run: uv pip install ".[dev,all_extras]" --no-cache-dir + run: uv pip install .[all_extras,dev] --no-cache-dir env: UV_SYSTEM_PYTHON: 1 - name: Show dependencies run: uv pip list - - name: Test with pytest - run: | - pytest ./tests + - name: Run tests + run: pytest ./tests codecov: - name: coverage (${{ matrix.python-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} + name: codecov (${{ matrix.python-version }}, ${{ matrix.os }}) needs: code-quality - env: - MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 + runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] @@ -159,9 +243,14 @@ jobs: - name: Display Python version run: python -c "import sys; print(sys.version)" + - name: Force non-GUI Matplotlib backend (Windows) + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: echo "MPLBACKEND=Agg" >> $env:GITHUB_ENV + - name: Install dependencies shell: bash - run: uv pip install ".[dev,all_extras]" --no-cache-dir + run: uv pip install .[all_extras,dev] --no-cache-dir env: UV_SYSTEM_PYTHON: 1 @@ -180,51 +269,3 @@ jobs: with: files: ./coverage.xml fail_ci_if_error: true - - notebooks: - needs: code-quality - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] - fail-fast: false - - steps: - - uses: actions/checkout@v6 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Display Python version - run: python -c "import sys; print(sys.version)" - - - name: Install dependencies - shell: bash - run: uv pip install ".[dev,all_extras,notebook_test]" --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 - - - name: Show dependencies - run: uv pip list - - # Discover all notebooks - - name: Collect notebooks - id: notebooks - shell: bash - run: | - NOTEBOOKS=$(find cookbook -name '*.ipynb' -print0 | xargs -0 echo) - echo "notebooks=$NOTEBOOKS" >> $GITHUB_OUTPUT - - # Run all discovered notebooks with nbmake - - name: Test notebooks - shell: bash - run: | - uv run pytest --reruns 3 --nbmake --nbmake-timeout=3600 -vv ${{ steps.notebooks.outputs.notebooks }} diff --git a/.github/workflows/release.yml b/.github/workflows/wheels.yml similarity index 53% rename from .github/workflows/release.yml rename to .github/workflows/wheels.yml index 3cf91533..f162d757 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/wheels.yml @@ -1,4 +1,4 @@ -name: PyPI Release +name: Build wheels and publish to PyPI on: release: @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: @@ -32,77 +32,84 @@ jobs: fi build_wheels: + needs: check_tag name: Build wheels runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - uses: actions/setup-python@v6 with: - python-version: '3.11' + python-version: '3.10' - name: Build wheel run: | - python -m pip install build - python -m build --wheel --sdist --outdir wheelhouse + uv pip install build + uv build --wheel --sdist --out-dir wheelhouse + env: + UV_SYSTEM_PYTHON: 1 - name: Store wheels - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: wheels path: wheelhouse/* - pytest-nosoftdeps: - name: no-softdeps + test_wheels: + needs: build_wheels + name: Test wheels on ${{ matrix.os }} with ${{ matrix.python-version }} runs-on: ${{ matrix.os }} - needs: [build_wheels] strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [windows-latest, ubuntu-latest, macos-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Setup macOS - if: runner.os == 'macOS' - run: | - brew install libomp # https://github.com/pytorch/pytorch/issues/20030 + - uses: actions/download-artifact@v7 + with: + name: wheels + path: wheelhouse - - name: Get full Python version - id: full-python-version - shell: bash - run: echo version=$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") >> $GITHUB_OUTPUT + - name: Display downloaded artifacts + run: ls -l wheelhouse - - name: Install dependencies - shell: bash - run: | - pip install ".[dev]" + - name: Get wheel filename (Unix) + if: runner.os != 'Windows' + run: echo "WHEELNAME=$(ls ./wheelhouse/pyportfolioopt-*none-any.whl)" >> $GITHUB_ENV + + - name: Get wheel filename (Windows) + if: runner.os == 'Windows' + run: echo "WHEELNAME=$(ls ./wheelhouse/pyportfolioopt-*none-any.whl)" >> $env:GITHUB_ENV - - name: Show dependencies - run: python -m pip list + - name: Install wheel and extras + run: python3 -m pip install "${{ env.WHEELNAME }}[all_extras,dev]" - - name: Run pytest - shell: bash + - name: Run tests run: python -m pytest tests upload_wheels: name: Upload wheels to PyPI runs-on: ubuntu-latest - needs: [pytest-nosoftdeps] + needs: [build_wheels, test_wheels] permissions: id-token: write steps: - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: name: wheels path: wheelhouse From c5ba2212fd90f4cec0782090a2e53722f70df4f1 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 21 Feb 2026 13:36:11 +0100 Subject: [PATCH 2/7] Adds uv.lock --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4b974123..41862b80 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ dist artifacts bin + +# uv +uv.lock From 04b200bde6c0c67d218acd35994189f8f2ff598e Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 21 Feb 2026 13:36:46 +0100 Subject: [PATCH 3/7] Use uv --- .github/workflows/test.yml | 38 +++++++------------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2885e00c..6118b470 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -102,10 +102,6 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: python-version: ${{ matrix.python-version }} - name: Display Python version @@ -113,9 +109,7 @@ jobs: - name: Install dependencies shell: bash - run: uv pip install .[dev,all_extras,notebook_test] --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 + run: uv sync --extra dev --extra all_extras --extra notebook_test --no-cache - name: Show dependencies run: uv pip list @@ -149,10 +143,6 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: python-version: ${{ matrix.python-version }} - name: Display Python version @@ -165,15 +155,13 @@ jobs: - name: Install dependencies shell: bash - run: uv pip install .[dev] --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 + run: uv sync --group dev --no-cache - name: Show dependencies run: uv pip list - name: Run tests - run: pytest ./tests + run: uv run pytest ./tests test-full: needs: test-nosoftdeps @@ -192,10 +180,6 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: python-version: ${{ matrix.python-version }} - name: Display Python version @@ -208,15 +192,13 @@ jobs: - name: Install dependencies shell: bash - run: uv pip install .[all_extras,dev] --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 + run: uv sync --extra all_extras --extra dev --no-cache - name: Show dependencies run: uv pip list - name: Run tests - run: pytest ./tests + run: uv run pytest ./tests codecov: name: codecov (${{ matrix.python-version }}, ${{ matrix.os }}) @@ -234,10 +216,6 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: python-version: ${{ matrix.python-version }} - name: Display Python version @@ -250,16 +228,14 @@ jobs: - name: Install dependencies shell: bash - run: uv pip install .[all_extras,dev] --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 + run: uv sync --extra all_extras --extra dev --no-cache - name: Show dependencies run: uv pip list - name: Generate coverage report run: | - pip install pytest pytest-cov + uv pip install pytest pytest-cov pytest --cov=./ --cov-report=xml - name: Upload coverage to Codecov From af307d1589a30f976cc4d2a6ef8ed8f91b182e3e Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 21 Feb 2026 13:38:29 +0100 Subject: [PATCH 4/7] Adds comment --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index dd22c0e0..a683325f 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ RESET := \033[0m UV_INSTALL_DIR := ./bin +# TODO: I think we shouldn't install uv locally inside the repository, but rather rely on the user having it installed globally. This is because uv is a tool that is meant to be used across multiple projects, and installing it locally in each project can lead to version conflicts and unnecessary duplication. Instead, we can specify in the documentation that users should have uv installed globally, and provide instructions on how to do so if they don't already have it ##@ Bootstrap install-uv: ## ensure uv (and uvx) are installed locally @mkdir -p ${UV_INSTALL_DIR} From aa943845a9b9e2d000afd6fa75900a7a3bd11762 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 21 Feb 2026 13:39:25 +0100 Subject: [PATCH 5/7] Use uv here as well --- .github/workflows/wheels.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index f162d757..108453a9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -43,11 +43,8 @@ jobs: uses: astral-sh/setup-uv@v7 with: enable-cache: true - - - uses: actions/setup-python@v6 - with: python-version: '3.10' - + - name: Build wheel run: | uv pip install build From f42002cef664509ad37d794b6dd7520deab97bf1 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 21 Feb 2026 13:56:37 +0100 Subject: [PATCH 6/7] Uses dependency groups instead of optionals, and adds requires-python --- .github/workflows/test.yml | 5 ++--- pyproject.toml | 18 +++++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6118b470..6b9aa4f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,7 +109,7 @@ jobs: - name: Install dependencies shell: bash - run: uv sync --extra dev --extra all_extras --extra notebook_test --no-cache + run: uv sync --extra all_extras --group dev --extra notebook_test --no-cache - name: Show dependencies run: uv pip list @@ -228,14 +228,13 @@ jobs: - name: Install dependencies shell: bash - run: uv sync --extra all_extras --extra dev --no-cache + run: uv sync --only-group dev --only-group cov - name: Show dependencies run: uv pip list - name: Generate coverage report run: | - uv pip install pytest pytest-cov pytest --cov=./ --cov-report=xml - name: Upload coverage to Codecov diff --git a/pyproject.toml b/pyproject.toml index 4a6e4cfd..61ad5c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ authors = [ { name = "Robert Andrew Martin", email = "martin.robertandrew@gmail.com" }, ] readme = "README.md" +requires-python = ">=3.10,<3.15" keywords= ["finance", "portfolio", "optimization", "quant", "investing"] classifiers=[ "Development Status :: 4 - Beta", @@ -58,13 +59,6 @@ all_extras = [ "cvxopt; python_version < '3.14'", ] -# dev - the developer dependency set, for contributors and CI -dev = [ - "pytest>=9.0.0", - "pytest-cov>=7.0.0", - "yfinance>=0.2.66", -] - # notebook tests notebook_test = [ "nbmake", @@ -108,6 +102,16 @@ indent-style = "space" line-ending = "auto" skip-magic-trailing-comma = false +[dependency-groups] +cov = [ + "coverage>=7.13.4", + "pytest-cov>=7.0.0", +] +dev = [ + "pytest>=9.0.2", + "yfinance>=1.2.0", +] + [tool.ruff.lint.isort] known-first-party = ["pypfopt"] combine-as-imports = true From 5bcb55214247f297a46e5d750050682140b3e48b Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 21 Feb 2026 14:55:36 +0100 Subject: [PATCH 7/7] Comment fix --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a683325f..2c577f98 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ RESET := \033[0m UV_INSTALL_DIR := ./bin -# TODO: I think we shouldn't install uv locally inside the repository, but rather rely on the user having it installed globally. This is because uv is a tool that is meant to be used across multiple projects, and installing it locally in each project can lead to version conflicts and unnecessary duplication. Instead, we can specify in the documentation that users should have uv installed globally, and provide instructions on how to do so if they don't already have it +# TODO: I don't think we should install uv locally inside the repository, but rather rely on the user having it installed globally. This is because uv is a tool that is meant to be used across multiple projects, and installing it locally in each project can lead to version conflicts and unnecessary duplication. Instead, we can specify in the documentation that users should have uv installed globally, and provide instructions on how to do so if they don't already have it ##@ Bootstrap install-uv: ## ensure uv (and uvx) are installed locally @mkdir -p ${UV_INSTALL_DIR}