diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7e8eeb2..897d608 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ # Pre-commit hooks for ferro-ta -# Install: pre-commit install +# Install: pre-commit install --hook-type pre-commit --hook-type pre-push # Run: pre-commit run --all-files default_language_version: python: python3 @@ -31,3 +31,13 @@ repos: # entry: mypy python/ferro_ta --ignore-missing-imports # language: system # pass_filenames: false + + - repo: local + hooks: + - id: ci-basic-pre-push + name: ci basic pre-push + entry: scripts/pre_push_checks.sh + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad4235b..cfbda68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,34 @@ uv run ruff check python/ tests/ uv run mypy python/ferro_ta --ignore-missing-imports ``` +## Git hooks and pre-push checks + +Install the repo-managed git hooks after syncing your environment: + +```bash +make hooks +``` + +That installs both the existing `pre-commit` hook and a `pre-push` hook that +runs the local CI gate before anything is pushed. + +To run the same gate manually: + +```bash +make prepush +``` + +To run only part of it while iterating: + +```bash +make prepush CHECKS="version changelog python_lint" +``` + +The pre-push runner covers the basic required CI categories we can execute +locally: version/changelog checks, Rust fmt/clippy/core checks, Python +lint/typecheck/tests, docs, and WASM. It intentionally skips the multi-version +matrix, audit, and benchmark-regression jobs. + ## Alternative: set up with plain pip ```bash diff --git a/Makefile b/Makefile index 966a9b8..5015682 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # ferro-ta development Makefile # Usage: make -.PHONY: help dev build test lint typecheck fmt docs clean bench version +.PHONY: help dev build test lint typecheck fmt docs clean bench version audit prepush hooks # Default target help: @@ -17,12 +17,14 @@ help: @echo " make bench Run Rust criterion benchmarks (ferro_ta_core)" @echo " make version Bump tracked version strings (set VERSION=X.Y.Z)" @echo " make audit Run cargo-audit + pip-audit" + @echo " make prepush Run the local pre-push CI gate (set CHECKS='version rust_fmt' to scope it)" + @echo " make hooks Install pre-commit and pre-push git hooks" @echo " make clean Remove build artefacts" dev: pip install uv uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml \ - sphinx sphinx-rtd-theme ruff mypy pyright + sphinx sphinx-rtd-theme ruff mypy pyright pre-commit build: maturin develop --release @@ -57,6 +59,12 @@ audit: cargo audit uv run --with pip-audit pip-audit +prepush: + bash scripts/pre_push_checks.sh $(CHECKS) + +hooks: + uv run --with pre-commit pre-commit install --hook-type pre-commit --hook-type pre-push + clean: cargo clean rm -rf dist/ docs/_build/ coverage.xml .coverage *.egg-info diff --git a/docs/contributing.rst b/docs/contributing.rst index 4bc7c3d..4ebf9ef 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -26,6 +26,28 @@ Prerequisites: Rust stable toolchain, Python 3.10+, and ``maturin``. pytest tests/ +Git hooks and pre-push checks +----------------------------- + +Install the repository-managed hooks after setting up the environment: + +.. code-block:: bash + + make hooks + +Run the same push gate manually with: + +.. code-block:: bash + + make prepush + +You can scope it to selected checks while iterating: + +.. code-block:: bash + + make prepush CHECKS="version changelog python_lint" + + Adding a new indicator ----------------------- diff --git a/pyproject.toml b/pyproject.toml index 3904bd9..79fece5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dev = [ "hypothesis>=6.0", "pandas>=1.0", "polars>=0.19", + "pre-commit>=3.0", "ruff>=0.3", "mypy>=1.0", "pyright>=1.1", @@ -130,6 +131,7 @@ dev = [ "hypothesis>=6.0", "pandas>=1.0", "polars>=0.19", + "pre-commit>=3.0", "ruff>=0.3", "mypy>=1.0", "pyright>=1.1", diff --git a/scripts/pre_push_checks.sh b/scripts/pre_push_checks.sh new file mode 100755 index 0000000..1e3ca6e --- /dev/null +++ b/scripts/pre_push_checks.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +AVAILABLE_CHECKS=( + version + changelog + rust_fmt + rust_clippy + rust_core + rust_bench + python_lint + python_typecheck + python_test + docs + wasm + manifest +) + +DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}") + +python_env_ready=0 + +usage() { + cat <<'EOF' +Usage: + scripts/pre_push_checks.sh + scripts/pre_push_checks.sh [ ...] + scripts/pre_push_checks.sh --list + +Runs the repo's basic local CI gate before push. By default it covers: + version changelog rust_fmt rust_clippy rust_core rust_bench + python_lint python_typecheck python_test docs wasm manifest + +Notes: + - This mirrors the required CI categories we can run locally. + - It intentionally skips the multi-Python test matrix, audit jobs, perf smoke, + and benchmark-regression jobs. +EOF +} + +list_checks() { + printf '%s\n' "${AVAILABLE_CHECKS[@]}" +} + +need_cmd() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Missing required command: $command_name" >&2 + exit 1 + fi +} + +run_cmd() { + printf ' +' + printf ' %q' "$@" + printf '\n' + "$@" +} + +ensure_python_env() { + if [[ "$python_env_ready" -eq 1 ]]; then + return + fi + + need_cmd uv + run_cmd uv sync --extra dev --extra docs + run_cmd uv run --extra dev --extra docs maturin develop --release + python_env_ready=1 +} + +run_version() { + need_cmd python3 + run_cmd python3 scripts/bump_version.py --check +} + +run_changelog() { + need_cmd python3 + run_cmd python3 scripts/check_changelog.py +} + +run_rust_fmt() { + need_cmd cargo + run_cmd cargo fmt --all -- --check +} + +run_rust_clippy() { + need_cmd cargo + run_cmd cargo clippy --release -- -D warnings +} + +run_rust_core() { + need_cmd cargo + run_cmd cargo build -p ferro_ta_core + run_cmd cargo test -p ferro_ta_core +} + +run_rust_bench() { + need_cmd cargo + run_cmd cargo bench -p ferro_ta_core --no-run +} + +run_python_lint() { + need_cmd uv + run_cmd uv run --with ruff ruff check python/ tests/ + run_cmd uv run --with ruff ruff format --check python/ tests/ +} + +run_python_typecheck() { + need_cmd uv + run_cmd uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary + run_cmd uv run --with pyright pyright python/ferro_ta +} + +run_python_test() { + ensure_python_env + run_cmd uv run --extra dev --with pytest-cov pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65 +} + +run_docs() { + ensure_python_env + run_cmd uv run --extra docs python -m sphinx -b html docs docs/_build -W --keep-going +} + +run_wasm() { + need_cmd node + need_cmd wasm-pack + local benchmark_json="../.wasm_benchmark.prepush.json" + ( + cd wasm + trap 'rm -f "$benchmark_json"' EXIT + run_cmd wasm-pack test --node + run_cmd wasm-pack build --target nodejs --out-dir pkg + run_cmd node bench.js --json "$benchmark_json" + ) +} + +run_manifest() { + need_cmd python3 + run_cmd python3 scripts/check_api_manifest.py +} + +run_check() { + local check_name="$1" + case "$check_name" in + version) run_version ;; + changelog) run_changelog ;; + rust_fmt) run_rust_fmt ;; + rust_clippy) run_rust_clippy ;; + rust_core) run_rust_core ;; + rust_bench) run_rust_bench ;; + python_lint) run_python_lint ;; + python_typecheck) run_python_typecheck ;; + python_test) run_python_test ;; + docs) run_docs ;; + wasm) run_wasm ;; + manifest) run_manifest ;; + *) + echo "Unknown check: $check_name" >&2 + echo "Use --list to see supported checks." >&2 + exit 1 + ;; + esac +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +if [[ "${1:-}" == "--list" ]]; then + list_checks + exit 0 +fi + +selected_checks=() +if [[ "$#" -gt 0 ]]; then + selected_checks=("$@") +else + selected_checks=("${DEFAULT_CHECKS[@]}") +fi + +total_checks="${#selected_checks[@]}" +index=0 +for check_name in "${selected_checks[@]}"; do + index=$((index + 1)) + printf '\n[%d/%d] %s\n' "$index" "$total_checks" "$check_name" + run_check "$check_name" +done + +printf '\nAll selected pre-push checks passed.\n' diff --git a/uv.lock b/uv.lock index def2fa8..49d09ef 100644 --- a/uv.lock +++ b/uv.lock @@ -180,6 +180,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.5" @@ -595,6 +604,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -657,7 +675,7 @@ wheels = [ [[package]] name = "ferro-ta" -version = "1.0.3" +version = "1.0.4" source = { editable = "." } dependencies = [ { name = "numpy" }, @@ -693,6 +711,7 @@ dev = [ { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "polars" }, + { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, { name = "pyyaml" }, @@ -731,6 +750,7 @@ dev = [ { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, { name = "pandas-ta", marker = "python_full_version >= '3.12'" }, { name = "polars" }, + { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, { name = "pyyaml" }, @@ -757,6 +777,7 @@ requires-dist = [ { name = "polars", marker = "extra == 'all'", specifier = ">=0.19" }, { name = "polars", marker = "extra == 'dev'", specifier = ">=0.19" }, { name = "polars", marker = "extra == 'polars'", specifier = ">=0.19" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, { name = "pytest", marker = "extra == 'all'", specifier = ">=7.0" }, { name = "pytest", marker = "extra == 'benchmark'", specifier = ">=7.0" }, @@ -783,6 +804,7 @@ dev = [ { name = "pandas", specifier = ">=1.0" }, { name = "pandas-ta", marker = "python_full_version >= '3.12'", specifier = ">=0.3" }, { name = "polars", specifier = ">=0.19" }, + { name = "pre-commit", specifier = ">=3.0" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=7.0" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -924,6 +946,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, ] +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -2046,6 +2077,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2083,6 +2123,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + [[package]] name = "py-cpuinfo" version = "9.0.0" @@ -2345,6 +2401,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -3162,6 +3231,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + [[package]] name = "zipp" version = "3.23.0"