Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions .github/actions/setup-project/action.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# Action: Setup Project (composite action)
#
# Purpose: Bootstrap a Python project in GitHub Actions by:
# - Installing Task, uv, and uvx into a local ./bin directory
# - Detecting presence of pyproject.toml and exposing it as an output
# - Creating a virtual environment with uv and syncing dependencies
# Purpose: Bootstrap a Python project within GitHub Actions by:
# - Installing uv and uvx into a local ./bin directory and adding it to PATH
# - Detecting the presence of pyproject.toml and exposing that as an output
# - Creating a virtual environment with uv and (optionally) syncing dependencies
#
# Inputs:
# - python-version: Python version used for the virtual environment (default: 3.12)
# - python-version: Python version for the uv-managed virtual environment (default: 3.12)
#
# Outputs:
# - pyproject_exists: "true" if pyproject.toml exists, otherwise "false"
#
# Notes:
# - Safe to run in repositories without pyproject.toml; dependency sync will be skipped.
# - Used by workflows such as CI, Book, Marimo, and Release.
# - Purely a CI helper — it does not modify repository files.

name: 'Setup Project'
description: 'Setup the project'
Expand All @@ -32,7 +32,7 @@ outputs:
runs:
using: 'composite'
steps:
- name: Set up task, uv, uvx and the venv
- name: Set up uv, uvx and the venv
shell: bash
run: |
mkdir -p bin
Expand All @@ -41,17 +41,9 @@ runs:
echo "Adding ./bin to PATH"
echo "$(pwd)/bin" >> $GITHUB_PATH

# Install Task
curl -fsSL https://taskfile.dev/install.sh | sh -s -- -d -b ./bin

# Install uv and uvx
curl -fsSL https://astral.sh/uv/install.sh | UV_INSTALL_DIR="./bin" sh

- name: Check version for task
shell: bash
run: |
task --version

- name: Check version for uv
shell: bash
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ dist

artifacts

bin
59 changes: 59 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
## Makefile — PyPortfolioOpt developer conveniences
#
# This Makefile exposes common local development tasks and a friendly
# `make help` index.
# Conventions used by the help generator:
# - Lines with `##` after a target are turned into help text.
# - Lines starting with `##@` create section headers in the help output.
# This file does not affect the library itself; it only streamlines dev workflows.

# 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 install-uv test fmt

UV_INSTALL_DIR := ./bin

##@ Bootstrap
install-uv: ## ensure uv (and uvx) are installed locally
@mkdir -p ${UV_INSTALL_DIR}
@if [ -x "${UV_INSTALL_DIR}/uv" ] && [ -x "${UV_INSTALL_DIR}/uvx" ]; then \
:; \
else \
printf "${BLUE}Installing uv${RESET}\n"; \
curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=${UV_INSTALL_DIR} sh 2>/dev/null || { printf "${RED}[ERROR] Failed to install uv ${RESET}\n"; exit 1; }; \
fi

install: install-uv ## install
@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; }
@printf "${BLUE}[INFO] Installing dependencies${RESET}\n"
@./bin/uv sync --all-extras --frozen || { printf "${RED}[ERROR] Failed to install dependencies${RESET}\n"; exit 1; }


##@ Development and Testing
test: install ## run all tests
@printf "${BLUE}[INFO] Running tests...${RESET}\n"
@./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

fmt: install-uv ## check the pre-commit hooks and the linting
@./bin/uvx pre-commit run --all-files
@./bin/uvx deptry .

##@ Meta
help: ## Display this help message
+@printf "$(BOLD)Usage:$(RESET)\n"
+@printf " make $(BLUE)<target>$(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)
64 changes: 64 additions & 0 deletions tests/test_makefile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from pathlib import Path
import re
import shutil
import subprocess

import pytest

ROOT = Path(__file__).resolve().parents[1]

ANSI_ESCAPE_RE = re.compile(
r"\x1B\[[0-?]*[ -/]*[@-~]" # basic CSI sequences
)


def _strip_ansi(s: str) -> str:
return ANSI_ESCAPE_RE.sub("", s)


@pytest.mark.skipif(shutil.which("make") is None, reason="make is not installed")
def test_make_help_outputs_expected_sections_and_targets():
# Ensure we run from repo root so that $(MAKEFILE_LIST) resolves correctly
assert (ROOT / "Makefile").exists(), "Makefile not found at repository root"

proc = subprocess.run(
["make", "help"],
cwd=str(ROOT),
capture_output=True,
text=True,
check=False,
)

# Capture and normalize output
out = _strip_ansi(proc.stdout)
err = _strip_ansi(proc.stderr)

assert proc.returncode == 0, (
f"`make help` exited with {proc.returncode}\nSTDOUT:\n{out}\nSTDERR:\n{err}"
)

# Basic headings from help target
assert "Usage:" in out
assert "Targets:" in out

# Section headers defined in Makefile
for section in [
"Bootstrap",
"Development and Testing",
"Meta",
]:
assert section in out, (
f"Section header '{section}' not found in help output.\nOutput was:\n{out}"
)

# Targets declared in Makefile should appear in help
for target in [
"install-uv",
"install",
"test",
"fmt",
"help",
]:
assert re.search(rf"\b{re.escape(target)}\b", out) is not None, (
f"Target '{target}' not found in help output.\nOutput was:\n{out}"
)
3 changes: 2 additions & 1 deletion tests/test_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ def test_readme_runs():
result_blocks = RESULT.findall(readme_text)

# Optional: keep docs and expectations in sync.
assert len(code_blocks) == len(result_blocks), (
assert len(code_blocks) >= len(result_blocks), (
"Mismatch between python and result blocks in README.md"
)

code = "".join(code_blocks) # merged code
expected = "".join(result_blocks)

Expand Down