From 51747c6c7aef9ba8bc807049aa8530999df0e0f2 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Mon, 10 Nov 2025 09:30:44 +0400 Subject: [PATCH 01/13] installing task via make --- .gitignore | 1 + Makefile | 36 +++++++++ Taskfile.yml | 74 ++++++++++++++++++ taskfiles/build.yml | 72 ++++++++++++++++++ taskfiles/cleanup.yml | 29 +++++++ taskfiles/docs.yml | 173 ++++++++++++++++++++++++++++++++++++++++++ taskfiles/quality.yml | 44 +++++++++++ 7 files changed, 429 insertions(+) create mode 100644 Makefile create mode 100644 Taskfile.yml create mode 100644 taskfiles/build.yml create mode 100644 taskfiles/cleanup.yml create mode 100644 taskfiles/docs.yml create mode 100644 taskfiles/quality.yml diff --git a/.gitignore b/.gitignore index e549c900..4b974123 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ dist artifacts +bin diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..621d662e --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +## Makefile for config-templates: developer tasks orchestrated via go-task +# +# This Makefile wraps the Taskfile.yml commands and provides a friendly +# `make help` index. Lines with `##` after a target are parsed into help text, +# and lines starting with `##@` create section headers in the help output. +# +# Colors for pretty output in help messages +BLUE := \033[36m +BOLD := \033[1m +GREEN := \033[32m +RED := \033[31m +RESET := \033[0m + +# Default goal when running `make` with no target +.DEFAULT_GOAL := help + +# Declare phony targets (they don't produce files) +.PHONY: install-task + +UV_INSTALL_DIR := "./bin" + +##@ Bootstrap +install-task: ## ensure go-task (Taskfile) is installed + @mkdir -p ${UV_INSTALL_DIR} + + @if [ ! -x "${UV_INSTALL_DIR}/task" ]; then \ + printf "$(BLUE)Installing go-task (Taskfile)$(RESET)\n"; \ + curl --location https://taskfile.dev/install.sh | sh -s -- -d -b ${UV_INSTALL_DIR}; \ + fi + +##@ Meta +help: ## Display this help message + +@printf "$(BOLD)Usage:$(RESET)\n" + +@printf " make $(BLUE)$(RESET)\n\n" + +@printf "$(BOLD)Targets:$(RESET)\n" + +@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " $(BLUE)%-15s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..ca8a4d7a --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,74 @@ +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Taskfile: Root Taskfile +# +# Purpose: Provide a unified entry point for developer tasks by grouping +# build, quality, documentation, and cleanup commands. Exposes +# helpful default and help targets. +# +# Components: +# - includes: build, quality, docs, cleanup +# - tasks: +# - default: show top-level tasks +# - help: show all tasks including nested ones + +version: '3' +silent: true + +# Variables +vars: + TESTS_FOLDER: tests + MARIMO_FOLDER: book/marimo + BOOK_TITLE: '{{env "REPO_NAME"}}' # defined below env section + BOOK_SUBTITLE: 'Documentation and Reports' + OPTIONS: '' + +# Environment variables +env: + # Colors for pretty output + BLUE: '\x1b[36m' + GREEN: '\x1b[32m' + RED: '\x1b[31m' + YELLOW: '\x1b[33m' + CYAN: '\x1b[36m' + MAGENTA: '\x1b[35m' + BOLD: '\x1b[1m' + RESET: '\x1b[0m' + # Set repository name + REPO_NAME: + sh: basename $(pwd) + + #LOG_INFO: 'printf "${BLUE}[INFO] %s${RESET}\n"' + #LOG_SUCCESS: 'printf "${GREEN}[SUCCESS] %s${RESET}\n"' + #LOG_ERROR: 'printf "${RED}[ERROR] %s${RESET}\n"' + + +# Include task groups +includes: + build: + taskfile: ./taskfiles/build.yml + desc: Build tasks for managing dependencies and building packages + quality: + taskfile: ./taskfiles/quality.yml + desc: Code quality tasks for formatting, linting, and checking code + docs: + taskfile: ./taskfiles/docs.yml + desc: Documentation tasks for building docs, notebooks, and books + cleanup: + taskfile: ./taskfiles/cleanup.yml + desc: Cleanup tasks for removing generated files and directories + +# Task definitions +tasks: + default: + desc: Display help information + silent: true + cmds: + - ./bin/task --list + + help: + desc: Display help information with all tasks + silent: true + cmds: + - ./bin/task --list-all diff --git a/taskfiles/build.yml b/taskfiles/build.yml new file mode 100644 index 00000000..59d6db4b --- /dev/null +++ b/taskfiles/build.yml @@ -0,0 +1,72 @@ +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Taskfile: Build tasks +# +# Purpose: Provide build-related utilities for Python projects using uv, including: +# - Installing uv and uvx into a local ./bin directory +# - Creating a virtual environment and syncing dependencies +# - Building the package with Hatch when a pyproject.toml is present +# +# Components: +# - uv: install uv/uvx if missing +# - install: create venv and install dependencies (if pyproject.toml exists) +# - build: build the package with Hatch (if pyproject.toml exists) + +version: '3' + +dir: . + +tasks: + uv: + desc: Install uv and uvx + env: + UV_INSTALL_DIR: ./bin + cmds: + - | + if [ -x "${UV_INSTALL_DIR}/uv" ] && [ -x "${UV_INSTALL_DIR}/uvx" ]; then + # already installed — stay quiet + exit 0 + fi + printf "${BLUE}Installing uv${RESET}\n" + curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null || { printf "${RED}[ERROR] Failed to install uv ${RESET}\n"; exit 1; } + + + install: + desc: Install all dependencies using uv + deps: [uv] + env: + UV_VENV_CLEAR: 1 + cmds: + - | + # we need the virtual environment at least for the tests to work + # even if we don't install anything + + printf "${BLUE}[INFO] Creating virtual environment...${RESET}\n" + + # Create the virtual environment + ./bin/uv venv --python 3.12 || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; } + + if [ -f "tests/requirements.txt" ]; then + ./bin/uv pip install -r tests/requirements.txt || { printf "${RED}[ERROR] Failed to install test requirements${RESET}\n"; exit 1; } + fi + + if [ -f "pyproject.toml" ]; then + printf "${BLUE}[INFO] Installing dependencies${RESET}\n" + ./bin/uv sync --all-extras --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; } + else + printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n" + fi + + build: + desc: Build the package using hatch + deps: [install] + cmds: + - | + if [ -f "pyproject.toml" ]; then + printf "${BLUE}[INFO] Building package...${RESET}\n" + ./bin/uv pip install hatch + ./bin/uv run hatch build + else + printf "${YELLOW}[WARN] No pyproject.toml found, skipping build${RESET}\n" + fi diff --git a/taskfiles/cleanup.yml b/taskfiles/cleanup.yml new file mode 100644 index 00000000..a2d7433d --- /dev/null +++ b/taskfiles/cleanup.yml @@ -0,0 +1,29 @@ +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Taskfile: Cleanup tasks +# +# Purpose: Remove build artifacts, caches, and orphaned Git branches to keep the +# working tree clean while preserving environment files. +# +# Components: +# - clean: delete generated files (dist, build, egg-info, caches) and prune +# local branches without a remote counterpart + +version: '3' + +tasks: + clean: + desc: Clean generated files and directories + cmds: + - | + printf "${BLUE}Cleaning project...${RESET}\n" + # do not clean .env files + git clean -d -X -f -e .env -e '.env.*' + rm -rf dist build *.egg-info .coverage .pytest_cache + printf "${BLUE}Removing local branches with no remote counterpart...${RESET}\n" + git fetch --prune + git branch -vv \ + | grep ': gone]' \ + | awk '{print $1}' \ + | xargs -r git branch -D 2>/dev/null || true diff --git a/taskfiles/docs.yml b/taskfiles/docs.yml new file mode 100644 index 00000000..974ff858 --- /dev/null +++ b/taskfiles/docs.yml @@ -0,0 +1,173 @@ +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Taskfile: Documentation and testing tasks +# +# Purpose: Provide tasks to run tests, generate API docs, export Marimo notebooks, +# assemble a combined documentation site ("book"), and run a Marimo server. +# +# Components: +# - test: run pytest with coverage and HTML report +# - docs: build API documentation with pdoc +# - marimushka: export Marimo notebooks to HTML and generate an index +# - book: assemble docs, test reports, and notebooks into a static site +# - marimo: start a Marimo server for interactive editing + +version: '3' + +includes: + build: + taskfile: ./build.yml + internal: true + +tasks: + test: + desc: Run all tests + cmds: + - ./bin/task build:install + - printf "${BLUE}[INFO] Running tests...${RESET}\n" + - | + # Find source folder + SOURCE_FOLDER="src/$(find src -mindepth 1 -maxdepth 1 -type d -not -path '*/\.*' | head -1 | sed 's|^src/||')" + + if [ -z "$SOURCE_FOLDER" ] || [ -z "{{.TESTS_FOLDER}}" ]; then + printf "${YELLOW}[WARN] No valid source folder structure found, skipping tests${RESET}\n" + else + ./bin/uv pip install pytest pytest-cov pytest-html + mkdir -p _tests/html-coverage _tests/html-report + ./bin/uv run pytest {{.TESTS_FOLDER}} --cov=$SOURCE_FOLDER --cov-report=term --cov-report=html:_tests/html-coverage --html=_tests/html-report/report.html + fi + + docs: + desc: Build documentation using pdoc + cmds: + - ./bin/task build:install + - | + if [ -f "pyproject.toml" ]; then + printf "${BLUE}[INFO] Building documentation...${RESET}\n" + if [ -d "src" ]; then + SOURCE_FOLDER=$(LC_ALL=C find src -mindepth 1 -maxdepth 1 -type d -not -path '*/\.*' | sort | head -1) + if [ -z "$SOURCE_FOLDER" ]; then + printf "${YELLOW}[WARN] src/ exists but contains no top-level packages, skipping docs${RESET}\n" + else + ./bin/uv pip install pdoc + ./bin/uv run pdoc -o _pdoc "$SOURCE_FOLDER" + fi + else + printf "${YELLOW}[WARN] No src/ directory found, skipping docs${RESET}\n" + fi + else + printf "${YELLOW}[WARN] No pyproject.toml found, skipping docs${RESET}\n" + fi + + marimushka: + desc: Export Marimo notebooks to HTML + cmds: + - ./bin/task build:install + - printf "${BLUE}[INFO] Exporting notebooks from {{.MARIMO_FOLDER}}...${RESET}\n" + - | + if [ ! -d "{{.MARIMO_FOLDER}}" ]; then + printf "${YELLOW}[WARN] Directory '{{.MARIMO_FOLDER}}' does not exist. Skipping marimushka.${RESET}\n" + else + ./bin/uv pip install marimo + mkdir -p _marimushka + py_files=$(find "{{.MARIMO_FOLDER}}" -maxdepth 1 -name "*.py" | tr '\n' ' ') + if [ -z "$py_files" ]; then + printf "${YELLOW}[WARN] No Python files found in '{{.MARIMO_FOLDER}}'.${RESET}\n" + echo "Marimo Notebooks

Marimo Notebooks

No notebooks found.

" > _marimushka/index.html + else + printf "${BLUE}[INFO] Found Python files: %s${RESET}\n" "$py_files" + for py_file in $py_files; do + printf " ${BLUE}[INFO] Processing %s...${RESET}\n" "$py_file" + rel_path=$(echo "$py_file" | sed "s|^{{.MARIMO_FOLDER}}/||") + dir_path=$(dirname "$rel_path") + base_name=$(basename "$rel_path" .py) + mkdir -p "_marimushka/$dir_path" + + # Check if the file has a script header ("# /// script") + if grep -q "^# /// script" "$py_file"; then + printf " ${BLUE}[INFO] Script header detected, using --sandbox flag...${RESET}\n" + ./bin/uvx marimo export html --sandbox --include-code --output "_marimushka/$dir_path/$base_name.html" "$py_file" + else + printf " ${BLUE}[INFO] No script header detected, using standard export...${RESET}\n" + ./bin/uv run marimo export html --include-code --output "_marimushka/$dir_path/$base_name.html" "$py_file" + fi + done + echo "Marimo Notebooks

Marimo Notebooks

" >> _marimushka/index.html + touch _marimushka/.nojekyll + fi + fi + + book: + desc: Build the companion book with test results and notebooks + cmds: + - ./bin/task build:install + - printf "${BLUE}[INFO] Building combined documentation...${RESET}\n" + - printf "${BLUE}[INFO] Delete the _book folder...${RESET}\n" + - rm -rf _book + - printf "${BLUE}[INFO] Create empty _book folder...${RESET}\n" + - mkdir -p _book + - touch _book/links.json + - | + printf "${BLUE}[INFO] Copy API docs...${RESET}\n" + if [ -d _pdoc ]; then + mkdir -p _book/pdoc + cp -r _pdoc/* _book/pdoc + echo '{"API": "./pdoc/index.html"}' > _book/links.json + else + echo '{}' > _book/links.json + fi + + printf "${BLUE}[INFO] Copy coverage report...${RESET}\n" + if [ -d _tests/html-coverage ] && [ "$(ls -A _tests/html-coverage 2>/dev/null)" ]; then + mkdir -p _book/tests/html-coverage + cp -r _tests/html-coverage/* _book/tests/html-coverage + jq '. + {"Coverage": "./tests/html-coverage/index.html"}' _book/links.json > _book/tmp && mv _book/tmp _book/links.json + else + printf "${YELLOW}[WARN] No coverage report found or directory is empty${RESET}\n" + fi + + printf "${BLUE}[INFO] Copy test report...${RESET}\n" + if [ -d _tests/html-report ] && [ "$(ls -A _tests/html-report 2>/dev/null)" ]; then + mkdir -p _book/tests/html-report + cp -r _tests/html-report/* _book/tests/html-report + jq '. + {"Test Report": "./tests/html-report/report.html"}' _book/links.json > _book/tmp && mv _book/tmp _book/links.json + else + printf "${YELLOW}[WARN] No test report found or directory is empty${RESET}\n" + fi + + printf "${BLUE}[INFO] Copy notebooks...${RESET}\n" + if [ -d _marimushka ] && [ "$(ls -A _marimushka 2>/dev/null)" ]; then + mkdir -p _book/marimushka + cp -r _marimushka/* _book/marimushka + jq '. + {"Notebooks": "./marimushka/index.html"}' _book/links.json > _book/tmp && mv _book/tmp _book/links.json + printf "${BLUE}[INFO] Copied notebooks from {{.MARIMO_FOLDER}} to _book/marimushka${RESET}\n" + else + printf "${YELLOW}[WARN] No notebooks found or directory is empty${RESET}\n" + fi + + printf "${BLUE}[INFO] Generated links.json:${RESET}\n" + cat _book/links.json + + ./bin/uvx minibook --title "{{.BOOK_TITLE}}" --subtitle "{{.BOOK_SUBTITLE}}" --links "$(cat _book/links.json)" --output "_book" + + touch "_book/.nojekyll" + + marimo: + desc: Start a Marimo server + cmds: + - ./bin/task build:install + - printf " ${BLUE}[INFO] Start Marimo server with {{.MARIMO_FOLDER}}...${RESET}\n" + - | + if [ ! -d "{{.MARIMO_FOLDER}}" ]; then + printf " ${YELLOW}[WARN] Marimo folder '{{.MARIMO_FOLDER}}' not found, skipping start${RESET}\n" + else + ./bin/uv sync --all-extras + ./bin/uv run marimo edit "{{.MARIMO_FOLDER}}" + fi diff --git a/taskfiles/quality.yml b/taskfiles/quality.yml new file mode 100644 index 00000000..2ea47e45 --- /dev/null +++ b/taskfiles/quality.yml @@ -0,0 +1,44 @@ +# This file is part of the tschm/.config-templates repository +# (https://github.com/tschm/.config-templates). +# +# Taskfile: Code quality tasks +# +# Purpose: Run automated code quality checks to maintain a healthy codebase. +# Uses pre-commit for formatting/linting and deptry for dependency hygiene. +# +# Components: +# - lint: run pre-commit hooks over the entire repository +# - deptry: analyze dependencies and detect issues +# - check: aggregate target to run both lint and deptry + +version: '3' + +includes: + build: + taskfile: ./build.yml + internal: true + +tasks: + lint: + desc: Run pre-commit hooks + cmds: + - ./bin/task build:uv --silent + - ./bin/uvx pre-commit run --all-files + + deptry: + desc: Check for dependency issues + cmds: + - ./bin/task build:uv --silent + - | + if [ -f "pyproject.toml" ]; then + SOURCE_FOLDER="src/$(find src -mindepth 1 -maxdepth 1 -type d -not -path '*/\.*' | head -1 | sed 's|^src/||')" + ./bin/uvx deptry $SOURCE_FOLDER {{.OPTIONS}} + else + printf "${YELLOW} No pyproject.toml found, skipping deptry${RESET}\n" + fi + + check: + desc: Run all code quality checks + deps: [lint, deptry] + cmds: + - printf "${GREEN} All checks passed!${RESET}\n" From ff74793bdab719d999df15ce98d56ca055ed67d7 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sat, 15 Nov 2025 18:38:50 +0400 Subject: [PATCH 02/13] more jobs for Makefile --- Makefile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 621d662e..df471e39 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,8 @@ RESET := \033[0m .DEFAULT_GOAL := help # Declare phony targets (they don't produce files) -.PHONY: install-task +.PHONY: install-task install clean test fmt + UV_INSTALL_DIR := "./bin" @@ -28,6 +29,19 @@ install-task: ## ensure go-task (Taskfile) is installed curl --location https://taskfile.dev/install.sh | sh -s -- -d -b ${UV_INSTALL_DIR}; \ fi +install: install-task ## install + @./bin/task build:install --silent + +clean: install-task ## clean + @./bin/task cleanup:clean --silent + +##@ Development and Testing +test: install-task ## run all tests + @./bin/task docs:test --silent + +fmt: install-task ## check the pre-commit hooks and the linting + @./bin/task quality:lint --silent + ##@ Meta help: ## Display this help message +@printf "$(BOLD)Usage:$(RESET)\n" From dd7894dc6a6c05dde410086e7cfd566a90444c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franz=20Kir=C3=A1ly?= Date: Fri, 14 Nov 2025 21:18:53 +0100 Subject: [PATCH 03/13] [MNT] merge all PR CI jobs into one workflow (#673) merges all PR CI jobs into one workflow, which allows gating and conditionals. Also makes all workflows conditional on basic code quality checks to avoid unnecessary runs. --- .github/workflows/codecov.yml | 55 ------------------- .github/workflows/main.yml | 97 +++++++++++++++++++++++++++++++++ .github/workflows/notebooks.yml | 57 ------------------- 3 files changed, 97 insertions(+), 112 deletions(-) delete mode 100644 .github/workflows/codecov.yml delete mode 100644 .github/workflows/notebooks.yml diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index ea8b6262..00000000 --- a/.github/workflows/codecov.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: codecov -on: - pull_request: - push: - branches: - - main - -jobs: - codecov: - name: py${{ matrix.python-version }} on ${{ 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.12"] - - steps: - - uses: actions/checkout@v5 - - - 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 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96dbc752..2d29a97f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -90,3 +90,100 @@ jobs: - name: Test with pytest run: | pytest ./tests + + codecov: + name: py${{ 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@v5 + + - 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: + runs-on: ubuntu-latest + needs: code-quality + + strategy: + matrix: + python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] + fail-fast: false + + steps: + - uses: actions/checkout@v5 + + - 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/notebooks.yml b/.github/workflows/notebooks.yml deleted file mode 100644 index dda5ae6e..00000000 --- a/.github/workflows/notebooks.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: notebooks - -on: - pull_request: - push: - branches: - - main -permissions: - contents: read - -jobs: - test: - 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@v5 - - - 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 }} From 643da6c1bac49cd0c4756144c4f0c4b5876bd6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franz=20Kir=C3=A1ly?= Date: Sat, 15 Nov 2025 00:07:19 +0100 Subject: [PATCH 04/13] [MNT] testing package without soft dependencies, isolate `matplotlib` (#662) * adds a CI job, `pytest-nosoftdeps`, to test the package without soft dependencies. * isolates soft dependency `matplotlib` in tests and the plotting module * isolates the `ecos` dependency almost entirely. There is one default that remains, which is deprecated and scheduled for removal in 1.7.0. * adds `scikit-base` soft dependency (without further dependencies) to manage soft dependencies This is also useful to check whether soft dependencies are properly isolated. --- .github/workflows/main.yml | 49 +++++++++++- pypfopt/discrete_allocation.py | 19 ++++- pypfopt/plotting.py | 23 +++++- pyproject.toml | 1 + tests/test_efficient_cdar.py | 9 +++ tests/test_efficient_cvar.py | 9 +++ tests/test_efficient_frontier.py | 17 +++++ tests/test_efficient_semivariance.py | 11 ++- tests/test_plotting.py | 110 ++++++++++++++++++++++++++- 9 files changed, 235 insertions(+), 13 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2d29a97f..7d1d2e5f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,9 +50,50 @@ jobs: echo "No changed files to check." fi - pytest: + pytest-nosoftdeps: needs: code-quality - name: py${{ matrix.python-version }} on ${{ matrix.os }} + 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@v5 + + - 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 @@ -92,7 +133,7 @@ jobs: pytest ./tests codecov: - name: py${{ matrix.python-version }} on ${{ matrix.os }} + name: coverage (${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} needs: code-quality env: @@ -141,8 +182,8 @@ jobs: fail_ci_if_error: true notebooks: - runs-on: ubuntu-latest needs: code-quality + runs-on: ubuntu-latest strategy: matrix: diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index d67f9116..4e4d8d4c 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -4,10 +4,12 @@ """ import collections +from warnings import warn import cvxpy as cp import numpy as np import pandas as pd +from skbase.utils.dependencies import _check_soft_dependencies from . import exceptions @@ -252,7 +254,8 @@ def greedy_portfolio(self, reinvest=False, verbose=False): self._allocation_rmse_error(verbose) return self.allocation, available_funds - def lp_portfolio(self, reinvest=False, verbose=False, solver="ECOS_BB"): + # todo 1.7.0: remove ECOS_BB defaulting behavior from docstring + def lp_portfolio(self, reinvest=False, verbose=False, solver=None): """ Convert continuous weights into a discrete portfolio allocation using integer programming. @@ -262,11 +265,23 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver="ECOS_BB"): :param verbose: print error analysis? :type verbose: bool :param solver: the CVXPY solver to use (must support mixed-integer programs) - :type solver: str, defaults to "ECOS_BB" + :type solver: str, defaults to "ECOS_BB" if ecos is installed, else None :return: the number of shares of each ticker that should be purchased, along with the amount of funds leftover. :rtype: (dict, float) """ + # todo 1.7.0: remove this defaulting behavior + if solver is None and _check_soft_dependencies("ecos", severity="none"): + solver = "ECOS_BB" + warn( + "The default solver for lp_portfolio will change from ECOS_BB to" + "None, the cvxpy default solver, in release 1.7.0." + "To continue using ECOS_BB as the solver, " + "please set solver='ECOS_BB' explicitly.", + FutureWarning, + ) + # end todo + if any([w < 0 for _, w in self.weights]): longs = {t: w for t, w in self.weights if w >= 0} shorts = {t: -w for t, w in self.weights if w < 0} diff --git a/pypfopt/plotting.py b/pypfopt/plotting.py index 54fcf48a..4bc3c8fa 100644 --- a/pypfopt/plotting.py +++ b/pypfopt/plotting.py @@ -16,10 +16,15 @@ from . import CLA, EfficientFrontier, exceptions, risk_models -try: - import matplotlib.pyplot as plt -except (ModuleNotFoundError, ImportError): # pragma: no cover - raise ImportError("Please install matplotlib via pip or poetry") + +def _import_matplotlib(): + """Helper function to import matplotlib only when needed""" + try: + import matplotlib.pyplot as plt + + return plt + except (ModuleNotFoundError, ImportError): # pragma: no cover + raise ImportError("Please install matplotlib via pip or poetry") def _get_plotly(): @@ -46,6 +51,8 @@ def _plot_io(**kwargs): :param showfig: whether to plt.show() the figure, defaults to False :type showfig: bool, optional """ + plt = _import_matplotlib() + filename = kwargs.get("filename", None) showfig = kwargs.get("showfig", False) dpi = kwargs.get("dpi", 300) @@ -73,6 +80,8 @@ def plot_covariance(cov_matrix, plot_correlation=False, show_tickers=True, **kwa :return: matplotlib axis :rtype: matplotlib.axes object """ + plt = _import_matplotlib() + if plot_correlation: matrix = risk_models.cov_to_corr(cov_matrix) else: @@ -110,6 +119,8 @@ def plot_dendrogram(hrp, ax=None, show_tickers=True, **kwargs): :return: matplotlib axis :rtype: matplotlib.axes object """ + plt = _import_matplotlib() + ax = ax or plt.gca() if hrp.clusters is None: @@ -337,6 +348,8 @@ def plot_efficient_frontier( :return: matplotlib axis :rtype: matplotlib.axes object """ + plt = _import_matplotlib() + if interactive: go, _ = _get_plotly() ax = go.Figure() @@ -393,6 +406,8 @@ def plot_weights(weights, ax=None, **kwargs): :return: matplotlib axis :rtype: matplotlib.axes """ + plt = _import_matplotlib() + ax = ax or plt.gca() desc = sorted(weights.items(), key=lambda x: x[1], reverse=True) diff --git a/pyproject.toml b/pyproject.toml index 9e7a45f8..f18b8fbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "pandas>=0.19", "scikit-learn>=0.24.1", "scipy>=1.3.0", + "scikit-base<0.14.0", ] [project.optional-dependencies] diff --git a/tests/test_efficient_cdar.py b/tests/test_efficient_cdar.py index 4ed6d57b..746efdec 100644 --- a/tests/test_efficient_cdar.py +++ b/tests/test_efficient_cdar.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import EfficientCDaR, expected_returns, objective_functions from pypfopt.exceptions import OptimizationError @@ -151,6 +152,10 @@ def test_min_cdar_extra_constraints(): assert w["GOOG"] >= 0.025 and w["MA"] <= 0.035 +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cdar_different_solver(): cd = setup_efficient_cdar(solver="ECOS") w = cd.min_cdar() @@ -182,6 +187,10 @@ def test_min_cdar_tx_costs(): assert np.abs(prev_w - w2).sum() < np.abs(prev_w - w1).sum() +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cdar_L2_reg(): cd = setup_efficient_cdar(solver="ECOS") cd.add_objective(objective_functions.L2_reg, gamma=0.1) diff --git a/tests/test_efficient_cvar.py b/tests/test_efficient_cvar.py index 14a2d863..2d20c79e 100644 --- a/tests/test_efficient_cvar.py +++ b/tests/test_efficient_cvar.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import EfficientCVaR, expected_returns, objective_functions from pypfopt.exceptions import OptimizationError @@ -156,6 +157,10 @@ def test_min_cvar_extra_constraints(): assert w["GOOG"] >= 0.025 and w["AAPL"] <= 0.035 +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cvar_different_solver(): cv = setup_efficient_cvar(solver="ECOS") w = cv.min_cvar() @@ -186,6 +191,10 @@ def test_min_cvar_tx_costs(): assert np.abs(prev_w - w2).sum() < np.abs(prev_w - w1).sum() +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cvar_L2_reg(): cv = setup_efficient_cvar(solver="ECOS") cv.add_objective(objective_functions.L2_reg, gamma=0.1) diff --git a/tests/test_efficient_frontier.py b/tests/test_efficient_frontier.py index 580f2dd5..4028e1a7 100644 --- a/tests/test_efficient_frontier.py +++ b/tests/test_efficient_frontier.py @@ -5,6 +5,7 @@ import pandas as pd import pytest import scipy.optimize as sco +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import ( EfficientFrontier, @@ -106,6 +107,10 @@ def test_min_volatility(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_volatility_different_solver(): ef = setup_efficient_frontier(solver="ECOS") w = ef.min_volatility() @@ -1042,6 +1047,10 @@ def test_efficient_risk_market_neutral_L2_reg(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_efficient_risk_market_neutral_warning(): ef = setup_efficient_frontier(solver=cp.ECOS) with pytest.warns(RuntimeWarning) as w: @@ -1088,6 +1097,10 @@ def test_efficient_frontier_error(): EfficientFrontier(ef.expected_returns, 0.01) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_efficient_return_many_values(): ef = setup_efficient_frontier(solver=cp.ECOS) for target_return in np.arange(0.25, 0.28, 0.01): @@ -1217,6 +1230,10 @@ def test_efficient_return_market_neutral_unbounded(): assert long_only_sharpe < sharpe +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_efficient_return_market_neutral_warning(): # This fails ef = setup_efficient_frontier(solver=cp.ECOS) diff --git a/tests/test_efficient_semivariance.py b/tests/test_efficient_semivariance.py index 4aa9cc51..7da9655b 100644 --- a/tests/test_efficient_semivariance.py +++ b/tests/test_efficient_semivariance.py @@ -1,6 +1,7 @@ +from cvxpy.error import SolverError import numpy as np import pytest -from cvxpy.error import SolverError +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import ( EfficientFrontier, @@ -176,6 +177,10 @@ def test_min_semivariance_extra_constraints(): assert w["GOOG"] >= 0.025 and w["AAPL"] <= 0.035 +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_semivariance_different_solver(): es = setup_efficient_semivariance(solver="ECOS") w = es.min_semivariance() @@ -347,6 +352,10 @@ def test_max_quadratic_utility_with_shorts(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_max_quadratic_utility_market_neutral(): es = setup_efficient_semivariance(solver="ECOS", weight_bounds=(-1, 1)) es.max_quadratic_utility(market_neutral=True) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 17697924..a4e5a3fb 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,11 +1,10 @@ import os import tempfile -import matplotlib -import matplotlib.pyplot as plt import numpy as np import pandas as pd import pytest +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import ( CLA, @@ -18,7 +17,13 @@ from tests.utilities_for_tests import get_data, setup_efficient_frontier +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_correlation_plot(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() S = risk_models.CovarianceShrinkage(df).ledoit_wolf() @@ -49,7 +54,14 @@ def test_correlation_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_dendrogram_plot(): + import matplotlib + import matplotlib.pyplot as plt + plt.figure() df = get_data() returns = df.pct_change().dropna(how="all") @@ -83,7 +95,13 @@ def test_dendrogram_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_cla_plot(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() rets = expected_returns.mean_historical_return(df) @@ -100,7 +118,13 @@ def test_cla_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_cla_plot_ax(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() rets = expected_returns.mean_historical_return(df) @@ -114,7 +138,13 @@ def test_cla_plot_ax(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_default_ef_plot(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ax = plotting.plot_efficient_frontier(ef, show_assets=True) @@ -131,7 +161,13 @@ def test_default_ef_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_default_ef_plot_labels(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ax = plotting.plot_efficient_frontier(ef, show_assets=True, show_tickers=True) @@ -139,7 +175,13 @@ def test_default_ef_plot_labels(): plt.clf() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_utility(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() delta_range = np.arange(0.001, 50, 1) @@ -151,7 +193,13 @@ def test_ef_plot_utility(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_errors(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() delta_range = np.arange(0.001, 50, 1) @@ -169,7 +217,13 @@ def test_ef_plot_errors(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_risk(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ef.min_volatility() @@ -185,7 +239,13 @@ def test_ef_plot_risk(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_return(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() # Internally _max_return() is used, so subtract epsilon @@ -199,7 +259,13 @@ def test_ef_plot_return(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_utility_short(): + import matplotlib.pyplot as plt + plt.figure() ef = EfficientFrontier( *setup_efficient_frontier(data_only=True), weight_bounds=(None, None) @@ -213,7 +279,13 @@ def test_ef_plot_utility_short(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_constrained_ef_plot_utility(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ef.add_constraint(lambda w: w[0] >= 0.2) @@ -229,7 +301,13 @@ def test_constrained_ef_plot_utility(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_constrained_ef_plot_risk(): + import matplotlib.pyplot as plt + plt.figure() ef = EfficientFrontier( *setup_efficient_frontier(data_only=True), weight_bounds=(None, None) @@ -249,7 +327,13 @@ def test_constrained_ef_plot_risk(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_weight_plot(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() returns = df.pct_change().dropna(how="all") @@ -262,7 +346,13 @@ def test_weight_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_weight_plot_multi(): + import matplotlib.pyplot as plt + ef = setup_efficient_frontier() w1 = ef.min_volatility() ef = setup_efficient_frontier() @@ -278,7 +368,13 @@ def test_weight_plot_multi(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_weight_plot_add_attribute(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() @@ -289,7 +385,13 @@ def test_weight_plot_add_attribute(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_plotting_edge_case(): + import matplotlib.pyplot as plt + # raised in issue #333 mu = pd.Series([0.043389, 0.036194]) S = pd.DataFrame([[0.000562, 0.002273], [0.002273, 0.027710]]) @@ -306,6 +408,10 @@ def test_plotting_edge_case(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_plot_efficient_frontier(): ef = setup_efficient_frontier() ef.min_volatility() From 6ea87e9c26b411534f4f38fd08ee2b0fdf998d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franz=20Kir=C3=A1ly?= Date: Sat, 15 Nov 2025 00:27:15 +0100 Subject: [PATCH 05/13] [MNT] restore `requirements.txt` for downwards compatibility (#675) restores the `requirements.txt` for downwards compatibility --- requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..7c90d815 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +cvxpy>=1.1.19 +numpy>=1.0.0 +pandas>=0.19 +scikit-base<0.14.0 +scikit-learn>=0.24.1 +scipy>=1.3.0 From 8479d861798733536f1bff57e8ce0ab32fa623bf Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sat, 15 Nov 2025 18:43:02 +0400 Subject: [PATCH 06/13] remove link to tschm/.config-templates --- taskfiles/build.yml | 3 --- taskfiles/cleanup.yml | 3 --- taskfiles/docs.yml | 3 --- taskfiles/quality.yml | 3 --- 4 files changed, 12 deletions(-) diff --git a/taskfiles/build.yml b/taskfiles/build.yml index 59d6db4b..8c3dc9ce 100644 --- a/taskfiles/build.yml +++ b/taskfiles/build.yml @@ -1,6 +1,3 @@ -# This file is part of the tschm/.config-templates repository -# (https://github.com/tschm/.config-templates). -# # Taskfile: Build tasks # # Purpose: Provide build-related utilities for Python projects using uv, including: diff --git a/taskfiles/cleanup.yml b/taskfiles/cleanup.yml index a2d7433d..30f6e3c2 100644 --- a/taskfiles/cleanup.yml +++ b/taskfiles/cleanup.yml @@ -1,6 +1,3 @@ -# This file is part of the tschm/.config-templates repository -# (https://github.com/tschm/.config-templates). -# # Taskfile: Cleanup tasks # # Purpose: Remove build artifacts, caches, and orphaned Git branches to keep the diff --git a/taskfiles/docs.yml b/taskfiles/docs.yml index 974ff858..abd5e969 100644 --- a/taskfiles/docs.yml +++ b/taskfiles/docs.yml @@ -1,6 +1,3 @@ -# This file is part of the tschm/.config-templates repository -# (https://github.com/tschm/.config-templates). -# # Taskfile: Documentation and testing tasks # # Purpose: Provide tasks to run tests, generate API docs, export Marimo notebooks, diff --git a/taskfiles/quality.yml b/taskfiles/quality.yml index 2ea47e45..a86280ed 100644 --- a/taskfiles/quality.yml +++ b/taskfiles/quality.yml @@ -1,6 +1,3 @@ -# This file is part of the tschm/.config-templates repository -# (https://github.com/tschm/.config-templates). -# # Taskfile: Code quality tasks # # Purpose: Run automated code quality checks to maintain a healthy codebase. From 8350176723b9a67af5202bde95eba921d63d0824 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sat, 15 Nov 2025 18:43:10 +0400 Subject: [PATCH 07/13] remove link to tschm/.config-templates --- Taskfile.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index ca8a4d7a..47a53e00 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,6 +1,3 @@ -# This file is part of the tschm/.config-templates repository -# (https://github.com/tschm/.config-templates). -# # Taskfile: Root Taskfile # # Purpose: Provide a unified entry point for developer tasks by grouping From a6104cd251659f36b61f8e82b875a56c2b03651e Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sat, 15 Nov 2025 18:46:34 +0400 Subject: [PATCH 08/13] Makefile cosmetics --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index df471e39..8753d27e 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,6 @@ RESET := \033[0m # Declare phony targets (they don't produce files) .PHONY: install-task install clean test fmt - UV_INSTALL_DIR := "./bin" ##@ Bootstrap @@ -42,6 +41,7 @@ test: install-task ## run all tests fmt: install-task ## check the pre-commit hooks and the linting @./bin/task quality:lint --silent + ##@ Meta help: ## Display this help message +@printf "$(BOLD)Usage:$(RESET)\n" From 235b9d3c63a6d822c94b4f9021f30e413ed7351f Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Sun, 16 Nov 2025 17:45:58 +0400 Subject: [PATCH 09/13] fmt --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8753d27e..4d71ec64 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -## Makefile for config-templates: developer tasks orchestrated via go-task +## Makefile for PyPortfolioOpt: developer tasks orchestrated via go-task # # This Makefile wraps the Taskfile.yml commands and provides a friendly # `make help` index. Lines with `##` after a target are parsed into help text, From 474ca11ab9cafeb153358e30857eeb61d6bc960e Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Wed, 19 Nov 2025 09:50:23 +0400 Subject: [PATCH 10/13] Remove MARIMO_FOLDER and logging variables Removed MARIMO_FOLDER variable and commented out logging variables. --- Taskfile.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 47a53e00..5f84b608 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,7 +16,6 @@ silent: true # Variables vars: TESTS_FOLDER: tests - MARIMO_FOLDER: book/marimo BOOK_TITLE: '{{env "REPO_NAME"}}' # defined below env section BOOK_SUBTITLE: 'Documentation and Reports' OPTIONS: '' @@ -36,11 +35,6 @@ env: REPO_NAME: sh: basename $(pwd) - #LOG_INFO: 'printf "${BLUE}[INFO] %s${RESET}\n"' - #LOG_SUCCESS: 'printf "${GREEN}[SUCCESS] %s${RESET}\n"' - #LOG_ERROR: 'printf "${RED}[ERROR] %s${RESET}\n"' - - # Include task groups includes: build: From 72bba8545790f16f5425fa80f9a204005bde0fd2 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Wed, 19 Nov 2025 09:51:21 +0400 Subject: [PATCH 11/13] Remove build step for Hatch in build.yml Removed build step for Hatch from the build configuration. --- taskfiles/build.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/taskfiles/build.yml b/taskfiles/build.yml index 8c3dc9ce..0780d716 100644 --- a/taskfiles/build.yml +++ b/taskfiles/build.yml @@ -3,12 +3,10 @@ # Purpose: Provide build-related utilities for Python projects using uv, including: # - Installing uv and uvx into a local ./bin directory # - Creating a virtual environment and syncing dependencies -# - Building the package with Hatch when a pyproject.toml is present # # Components: # - uv: install uv/uvx if missing # - install: create venv and install dependencies (if pyproject.toml exists) -# - build: build the package with Hatch (if pyproject.toml exists) version: '3' @@ -54,16 +52,3 @@ tasks: else printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n" fi - - build: - desc: Build the package using hatch - deps: [install] - cmds: - - | - if [ -f "pyproject.toml" ]; then - printf "${BLUE}[INFO] Building package...${RESET}\n" - ./bin/uv pip install hatch - ./bin/uv run hatch build - else - printf "${YELLOW}[WARN] No pyproject.toml found, skipping build${RESET}\n" - fi From c3dc6e5dbc79b2da7aff144da52fb6caf5cbbd4f Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Wed, 19 Nov 2025 09:58:10 +0400 Subject: [PATCH 12/13] reduced task files --- taskfiles/docs.yml | 134 ------------------------------------------ taskfiles/quality.yml | 8 +-- 2 files changed, 1 insertion(+), 141 deletions(-) diff --git a/taskfiles/docs.yml b/taskfiles/docs.yml index abd5e969..bfe2e9c1 100644 --- a/taskfiles/docs.yml +++ b/taskfiles/docs.yml @@ -34,137 +34,3 @@ tasks: mkdir -p _tests/html-coverage _tests/html-report ./bin/uv run pytest {{.TESTS_FOLDER}} --cov=$SOURCE_FOLDER --cov-report=term --cov-report=html:_tests/html-coverage --html=_tests/html-report/report.html fi - - docs: - desc: Build documentation using pdoc - cmds: - - ./bin/task build:install - - | - if [ -f "pyproject.toml" ]; then - printf "${BLUE}[INFO] Building documentation...${RESET}\n" - if [ -d "src" ]; then - SOURCE_FOLDER=$(LC_ALL=C find src -mindepth 1 -maxdepth 1 -type d -not -path '*/\.*' | sort | head -1) - if [ -z "$SOURCE_FOLDER" ]; then - printf "${YELLOW}[WARN] src/ exists but contains no top-level packages, skipping docs${RESET}\n" - else - ./bin/uv pip install pdoc - ./bin/uv run pdoc -o _pdoc "$SOURCE_FOLDER" - fi - else - printf "${YELLOW}[WARN] No src/ directory found, skipping docs${RESET}\n" - fi - else - printf "${YELLOW}[WARN] No pyproject.toml found, skipping docs${RESET}\n" - fi - - marimushka: - desc: Export Marimo notebooks to HTML - cmds: - - ./bin/task build:install - - printf "${BLUE}[INFO] Exporting notebooks from {{.MARIMO_FOLDER}}...${RESET}\n" - - | - if [ ! -d "{{.MARIMO_FOLDER}}" ]; then - printf "${YELLOW}[WARN] Directory '{{.MARIMO_FOLDER}}' does not exist. Skipping marimushka.${RESET}\n" - else - ./bin/uv pip install marimo - mkdir -p _marimushka - py_files=$(find "{{.MARIMO_FOLDER}}" -maxdepth 1 -name "*.py" | tr '\n' ' ') - if [ -z "$py_files" ]; then - printf "${YELLOW}[WARN] No Python files found in '{{.MARIMO_FOLDER}}'.${RESET}\n" - echo "Marimo Notebooks

Marimo Notebooks

No notebooks found.

" > _marimushka/index.html - else - printf "${BLUE}[INFO] Found Python files: %s${RESET}\n" "$py_files" - for py_file in $py_files; do - printf " ${BLUE}[INFO] Processing %s...${RESET}\n" "$py_file" - rel_path=$(echo "$py_file" | sed "s|^{{.MARIMO_FOLDER}}/||") - dir_path=$(dirname "$rel_path") - base_name=$(basename "$rel_path" .py) - mkdir -p "_marimushka/$dir_path" - - # Check if the file has a script header ("# /// script") - if grep -q "^# /// script" "$py_file"; then - printf " ${BLUE}[INFO] Script header detected, using --sandbox flag...${RESET}\n" - ./bin/uvx marimo export html --sandbox --include-code --output "_marimushka/$dir_path/$base_name.html" "$py_file" - else - printf " ${BLUE}[INFO] No script header detected, using standard export...${RESET}\n" - ./bin/uv run marimo export html --include-code --output "_marimushka/$dir_path/$base_name.html" "$py_file" - fi - done - echo "Marimo Notebooks

Marimo Notebooks

    " > _marimushka/index.html - find _marimushka -name "*.html" -not -path "*index.html" | sort | while read html_file; do - rel_path=$(echo "$html_file" | sed "s|^_marimushka/||") - name=$(basename "$rel_path" .html) - echo "
  • $name
  • " >> _marimushka/index.html - done - echo "
" >> _marimushka/index.html - touch _marimushka/.nojekyll - fi - fi - - book: - desc: Build the companion book with test results and notebooks - cmds: - - ./bin/task build:install - - printf "${BLUE}[INFO] Building combined documentation...${RESET}\n" - - printf "${BLUE}[INFO] Delete the _book folder...${RESET}\n" - - rm -rf _book - - printf "${BLUE}[INFO] Create empty _book folder...${RESET}\n" - - mkdir -p _book - - touch _book/links.json - - | - printf "${BLUE}[INFO] Copy API docs...${RESET}\n" - if [ -d _pdoc ]; then - mkdir -p _book/pdoc - cp -r _pdoc/* _book/pdoc - echo '{"API": "./pdoc/index.html"}' > _book/links.json - else - echo '{}' > _book/links.json - fi - - printf "${BLUE}[INFO] Copy coverage report...${RESET}\n" - if [ -d _tests/html-coverage ] && [ "$(ls -A _tests/html-coverage 2>/dev/null)" ]; then - mkdir -p _book/tests/html-coverage - cp -r _tests/html-coverage/* _book/tests/html-coverage - jq '. + {"Coverage": "./tests/html-coverage/index.html"}' _book/links.json > _book/tmp && mv _book/tmp _book/links.json - else - printf "${YELLOW}[WARN] No coverage report found or directory is empty${RESET}\n" - fi - - printf "${BLUE}[INFO] Copy test report...${RESET}\n" - if [ -d _tests/html-report ] && [ "$(ls -A _tests/html-report 2>/dev/null)" ]; then - mkdir -p _book/tests/html-report - cp -r _tests/html-report/* _book/tests/html-report - jq '. + {"Test Report": "./tests/html-report/report.html"}' _book/links.json > _book/tmp && mv _book/tmp _book/links.json - else - printf "${YELLOW}[WARN] No test report found or directory is empty${RESET}\n" - fi - - printf "${BLUE}[INFO] Copy notebooks...${RESET}\n" - if [ -d _marimushka ] && [ "$(ls -A _marimushka 2>/dev/null)" ]; then - mkdir -p _book/marimushka - cp -r _marimushka/* _book/marimushka - jq '. + {"Notebooks": "./marimushka/index.html"}' _book/links.json > _book/tmp && mv _book/tmp _book/links.json - printf "${BLUE}[INFO] Copied notebooks from {{.MARIMO_FOLDER}} to _book/marimushka${RESET}\n" - else - printf "${YELLOW}[WARN] No notebooks found or directory is empty${RESET}\n" - fi - - printf "${BLUE}[INFO] Generated links.json:${RESET}\n" - cat _book/links.json - - ./bin/uvx minibook --title "{{.BOOK_TITLE}}" --subtitle "{{.BOOK_SUBTITLE}}" --links "$(cat _book/links.json)" --output "_book" - - touch "_book/.nojekyll" - - marimo: - desc: Start a Marimo server - cmds: - - ./bin/task build:install - - printf " ${BLUE}[INFO] Start Marimo server with {{.MARIMO_FOLDER}}...${RESET}\n" - - | - if [ ! -d "{{.MARIMO_FOLDER}}" ]; then - printf " ${YELLOW}[WARN] Marimo folder '{{.MARIMO_FOLDER}}' not found, skipping start${RESET}\n" - else - ./bin/uv sync --all-extras - ./bin/uv run marimo edit "{{.MARIMO_FOLDER}}" - fi diff --git a/taskfiles/quality.yml b/taskfiles/quality.yml index a86280ed..2eb0fcc3 100644 --- a/taskfiles/quality.yml +++ b/taskfiles/quality.yml @@ -26,13 +26,7 @@ tasks: desc: Check for dependency issues cmds: - ./bin/task build:uv --silent - - | - if [ -f "pyproject.toml" ]; then - SOURCE_FOLDER="src/$(find src -mindepth 1 -maxdepth 1 -type d -not -path '*/\.*' | head -1 | sed 's|^src/||')" - ./bin/uvx deptry $SOURCE_FOLDER {{.OPTIONS}} - else - printf "${YELLOW} No pyproject.toml found, skipping deptry${RESET}\n" - fi + - ./bin/uvx deptry . check: desc: Run all code quality checks From 75ebd2563d89f30abe3497b2109b87078cc33fb5 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Wed, 19 Nov 2025 10:51:29 +0400 Subject: [PATCH 13/13] simples (but less generic) taskfiles --- taskfiles/build.yml | 11 ++--------- taskfiles/docs.yml | 13 +++---------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/taskfiles/build.yml b/taskfiles/build.yml index 0780d716..e0ce10ad 100644 --- a/taskfiles/build.yml +++ b/taskfiles/build.yml @@ -42,13 +42,6 @@ tasks: # Create the virtual environment ./bin/uv venv --python 3.12 || { printf "${RED}[ERROR] Failed to create virtual environment${RESET}\n"; exit 1; } - if [ -f "tests/requirements.txt" ]; then - ./bin/uv pip install -r tests/requirements.txt || { printf "${RED}[ERROR] Failed to install test requirements${RESET}\n"; exit 1; } - fi + printf "${BLUE}[INFO] Installing dependencies${RESET}\n" + ./bin/uv sync --all-extras --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; } - if [ -f "pyproject.toml" ]; then - printf "${BLUE}[INFO] Installing dependencies${RESET}\n" - ./bin/uv sync --all-extras --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; } - else - printf "${YELLOW}[WARN] No pyproject.toml found, skipping install${RESET}\n" - fi diff --git a/taskfiles/docs.yml b/taskfiles/docs.yml index bfe2e9c1..662d3713 100644 --- a/taskfiles/docs.yml +++ b/taskfiles/docs.yml @@ -24,13 +24,6 @@ tasks: - ./bin/task build:install - printf "${BLUE}[INFO] Running tests...${RESET}\n" - | - # Find source folder - SOURCE_FOLDER="src/$(find src -mindepth 1 -maxdepth 1 -type d -not -path '*/\.*' | head -1 | sed 's|^src/||')" - - if [ -z "$SOURCE_FOLDER" ] || [ -z "{{.TESTS_FOLDER}}" ]; then - printf "${YELLOW}[WARN] No valid source folder structure found, skipping tests${RESET}\n" - else - ./bin/uv pip install pytest pytest-cov pytest-html - mkdir -p _tests/html-coverage _tests/html-report - ./bin/uv run pytest {{.TESTS_FOLDER}} --cov=$SOURCE_FOLDER --cov-report=term --cov-report=html:_tests/html-coverage --html=_tests/html-report/report.html - fi + ./bin/uv pip install pytest pytest-cov pytest-html + mkdir -p _tests/html-coverage _tests/html-report + ./bin/uv run pytest tests --cov=pypfopt --cov-report=term --cov-report=html:_tests/html-coverage --html=_tests/html-report/report.html