diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 6cc45e03..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,230 +0,0 @@ -# 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 - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - code-quality: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-python@v6 - with: - python-version: '3.14' - - - name: install pre-commit - run: python3 -m pip install pre-commit - - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Get changed files - id: changed-files - run: | - CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | tr '\n' ' ') - echo "CHANGED_FILES=${CHANGED_FILES}" >> $GITHUB_ENV - - - name: Print changed files - run: | - echo "Changed files: $CHANGED_FILES" - - - name: Run pre-commit on changed files - run: | - if [ -n "$CHANGED_FILES" ]; then - pre-commit run --color always --files $CHANGED_FILES --show-diff-on-failure - else - echo "No changed files to check." - fi - - pytest-nosoftdeps: - needs: code-quality - name: nosoftdeps (${{ matrix.python-version }}, ${{ matrix.os }}) - runs-on: ${{ matrix.os }} - env: - MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 - strategy: - 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 - - - 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]" --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 - - - name: Show dependencies - run: uv pip list - - - name: Test with pytest - run: | - pytest ./tests - - pytest: - needs: pytest-nosoftdeps - name: (${{ matrix.python-version }}, ${{ matrix.os }}) - runs-on: ${{ matrix.os }} - env: - MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 - strategy: - 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 - - - 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]" --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 - - - name: Show dependencies - run: uv pip list - - - name: Test with pytest - run: | - pytest ./tests - - codecov: - name: coverage (${{ matrix.python-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - needs: code-quality - env: - MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.12"] - - 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]" --no-cache-dir - env: - UV_SYSTEM_PYTHON: 1 - - - name: Show dependencies - run: uv pip list - - - name: Generate coverage report - run: | - pip install pytest pytest-cov - pytest --cov=./ --cov-report=xml - - - name: Upload coverage to Codecov - # if false in order to skip for now - if: false - uses: codecov/codecov-action@v5 - 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/release.yml index 3cf91533..0d22057b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.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,80 @@ jobs: fi build_wheels: + needs: check_tag name: Build wheels runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: + enable-cache: true python-version: '3.11' - + - name: Build wheel run: | - python -m pip install build - python -m build --wheel --sdist --outdir wheelhouse + 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] --group 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..fdcda747 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,256 @@ +name: Install and test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + code-quality: + name: code-quality + runs-on: ubuntu-latest + steps: + - name: repository checkout step + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: "3.14" + + - name: Create virtual environment + run: uv venv + + - name: install pre-commit + run: uv pip install pre-commit + + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed-files + run: | + CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | tr '\n' ' ') + echo "CHANGED_FILES=${CHANGED_FILES}" >> $GITHUB_ENV + + - name: Print changed files + run: | + echo "Changed files:" && echo "$CHANGED_FILES" | tr ' ' '\n' + + - name: Run pre-commit on changed files + run: | + if [ -n "$CHANGED_FILES" ]; then + pre-commit run --color always --files $CHANGED_FILES --show-diff-on-failure + else + echo "No changed files to check." + fi + + 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 + python-version: ${{ matrix.python-version }} + + - name: Display Python version + run: python -c "import sys; print(sys.version)" + + - name: Create virtual environment + run: uv venv + + - name: Install dependencies + shell: bash + run: uv pip install ".[all_extras,notebook_test]" --group dev --no-cache + + - 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: test-nosoftdeps (${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Create virtual environment + run: uv venv + + - 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 "." --group dev --no-cache + + - name: Show dependencies + run: uv pip list + + - name: Run tests + run: uv run pytest ./tests + + test-full: + needs: test-nosoftdeps + name: test-full (${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Create virtual environment + run: uv venv + + - 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 -e ".[all_extras]" --group dev --no-cache + + - name: Show dependencies + run: uv pip list + + - name: Run tests + run: uv run pytest ./tests + + # TODO: should we run this as a substep of test-no-deps and only upload for a specific version? + codecov: + name: coverage (${{ matrix.python-version }} on ${{ matrix.os }} + needs: code-quality + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.12"] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Create virtual environment + run: uv venv + + - 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 . --group dev --group cov + + - name: Show dependencies + run: uv pip list + + - name: Generate coverage report + run: | + uv run pytest --cov=./ --cov-report=xml + + - name: Upload coverage to Codecov + # if false in order to skip for now + if: false + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 4b974123..41862b80 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ dist artifacts bin + +# uv +uv.lock diff --git a/Makefile b/Makefile index dd22c0e0..2c577f98 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ RESET := \033[0m UV_INSTALL_DIR := ./bin +# 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} 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