diff --git a/.github/workflows/polars-ci.yml b/.github/workflows/polars-ci.yml new file mode 100644 index 0000000..771ce2e --- /dev/null +++ b/.github/workflows/polars-ci.yml @@ -0,0 +1,67 @@ +name: Polars CI + +on: + pull_request: + types: [review_requested, ready_for_review] + paths: + - ".github/workflows/polars-*.yml" + - "Cargo.toml" + - "Makefile" + - "core/**" + - "polars/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - uses: astral-sh/setup-uv@v5 + - uses: dtolnay/rust-toolchain@stable + + - name: Sync polars dependencies + working-directory: polars + run: uv sync --group dev --no-install-project + + - name: Test techr-core + run: cargo test -p techr-core + + - name: Build local extension for tests + working-directory: polars + run: uv run maturin develop --uv + + - name: Test polars package + working-directory: polars + run: uv run pytest + + - name: Build wheel and sdist + working-directory: polars + run: uv run maturin build --release --sdist --out dist + + - name: Check artifact contents + run: python polars/scripts/check_artifacts.py polars/dist + + - name: Smoke test built wheel + run: | + wheel="$(python - <<'PY' + from pathlib import Path + + wheels = sorted(Path('polars/dist').glob('*.whl')) + if len(wheels) != 1: + raise SystemExit(f"expected exactly one wheel, found {len(wheels)}") + print(wheels[0].resolve().as_posix()) + PY + )" + uv run --isolated --python 3.10 --with "$wheel" python polars/scripts/smoke_import.py diff --git a/.github/workflows/polars-release.yml b/.github/workflows/polars-release.yml new file mode 100644 index 0000000..35fb111 --- /dev/null +++ b/.github/workflows/polars-release.yml @@ -0,0 +1,219 @@ +name: Polars Release + +on: + push: + tags: + - "polars-v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-linux: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64 + artifact: wheels-linux-x86_64 + - target: aarch64 + artifact: wheels-linux-aarch64 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - uses: astral-sh/setup-uv@v5 + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + working-directory: polars + target: ${{ matrix.target }} + manylinux: "2014" + sccache: "true" + args: --release --out dist -i python3.10 + + - name: Check artifact contents + run: python polars/scripts/check_artifacts.py polars/dist + + - name: Smoke test x86_64 wheel + if: matrix.target == 'x86_64' + run: | + wheel="$(python - <<'PY' + from pathlib import Path + + wheels = sorted(Path('polars/dist').glob('*.whl')) + if len(wheels) != 1: + raise SystemExit(f"expected exactly one wheel, found {len(wheels)}") + print(wheels[0].resolve().as_posix()) + PY + )" + uv run --isolated --python 3.10 --with "$wheel" python polars/scripts/smoke_import.py + + - name: Smoke test aarch64 wheel + if: matrix.target == 'aarch64' + uses: uraimo/run-on-arch-action@v2 + with: + arch: aarch64 + distro: ubuntu22.04 + githubToken: ${{ github.token }} + install: | + apt-get update + apt-get install -y --no-install-recommends python3 python3-pip + pip3 install -U pip + run: | + set -e + pip3 install polars/dist/*.whl --force-reinstall + python3 polars/scripts/smoke_import.py + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: polars/dist/* + + build-macos: + runs-on: macos-14 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - uses: astral-sh/setup-uv@v5 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-apple-darwin + - name: Build universal2 wheel + uses: PyO3/maturin-action@v1 + with: + working-directory: polars + sccache: "true" + args: --release --out dist --universal2 + + - name: Check artifact contents + run: python polars/scripts/check_artifacts.py polars/dist + + - name: Smoke test built wheel + run: | + wheel="$(python - <<'PY' + from pathlib import Path + + wheels = sorted(Path('polars/dist').glob('*.whl')) + if len(wheels) != 1: + raise SystemExit(f"expected exactly one wheel, found {len(wheels)}") + print(wheels[0].resolve().as_posix()) + PY + )" + uv run --isolated --python 3.10 --with "$wheel" python polars/scripts/smoke_import.py + + - uses: actions/upload-artifact@v4 + with: + name: wheels-macos-universal2 + path: polars/dist/* + + build-windows: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + - uses: astral-sh/setup-uv@v5 + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + working-directory: polars + target: x86_64-pc-windows-msvc + sccache: "true" + args: --release --out dist + + - name: Check artifact contents + run: python polars/scripts/check_artifacts.py polars/dist + + - name: Smoke test built wheel + run: | + wheel="$(python - <<'PY' + from pathlib import Path + + wheels = sorted(Path('polars/dist').glob('*.whl')) + if len(wheels) != 1: + raise SystemExit(f"expected exactly one wheel, found {len(wheels)}") + print(wheels[0].resolve().as_posix()) + PY + )" + uv run --isolated --python 3.10 --with "$wheel" python polars/scripts/smoke_import.py + + - uses: actions/upload-artifact@v4 + with: + name: wheels-windows-x64 + path: polars/dist/* + + build-sdist: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + working-directory: polars + command: sdist + args: --out dist + + - name: Check artifact contents + run: python polars/scripts/check_artifacts.py polars/dist + + - uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: polars/dist/* + + publish-testpypi: + if: github.event_name == 'workflow_dispatch' + needs: [build-linux, build-macos, build-windows, build-sdist] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: dist + skip-existing: true + + publish-pypi: + if: github.event_name == 'push' + needs: [build-linux, build-macos, build-windows, build-sdist] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/Makefile b/Makefile index cbf128b..b981a92 100644 --- a/Makefile +++ b/Makefile @@ -6,14 +6,15 @@ export CARGO_TERM_COLOR=$(shell (test -t 0 && echo "always") || echo "auto") .PHONY: build-dev-polars build-dev-polars: - @rm -f polars/polars_techr/*.so + @rm -f polars/polars_techr/*.so polars/polars_techr/*.pyd cd polars && uv run maturin develop --uv .PHONY: build-prod-polars build-prod-polars: - @rm -f polars/polars_techr/*.so - cd polars && uv run maturin build --release + @rm -f polars/polars_techr/*.so polars/polars_techr/*.pyd + @rm -rf polars/dist + cd polars && uv run maturin build --release --sdist --out dist .PHONY: test-core @@ -22,6 +23,7 @@ test-core: .PHONY: test-polars test-polars: + cd polars && uv run maturin develop --uv cd polars && uv run pytest .PHONY: test diff --git a/core/Cargo.toml b/core/Cargo.toml index d2aec3d..30b8896 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -2,6 +2,11 @@ name = "techr-core" version = "0.1.0" edition = "2021" +description = "Rust core implementation for techr technical indicators" +license = "MIT" +readme = "../README.md" +homepage = "https://github.com/alphaprime-dev/techr" +repository = "https://github.com/alphaprime-dev/techr" [lib] name = "techr" diff --git a/polars/Cargo.toml b/polars/Cargo.toml index 9f170b4..2d69068 100644 --- a/polars/Cargo.toml +++ b/polars/Cargo.toml @@ -2,9 +2,17 @@ name = "polars_techr" version = "0.1.0" edition = "2021" +description = "Polars expression plugins for techr indicators" +license = "MIT" +readme = "README.md" +homepage = "https://github.com/alphaprime-dev/techr" +repository = "https://github.com/alphaprime-dev/techr" + +[lib] +crate-type = ["cdylib"] [dependencies] -pyo3 = { version = "0.22.2", features = ["extension-module", "abi3-py38"] } +pyo3 = { version = "0.22.2", features = ["extension-module", "abi3-py310"] } pyo3-polars = { version = "0.17.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } polars = { version = "0.43.1", default-features = false } diff --git a/polars/README.md b/polars/README.md index 48961b5..61c2171 100644 --- a/polars/README.md +++ b/polars/README.md @@ -1,6 +1,12 @@ # polars-techr -Python wrapper for `techr` indicators on top of Polars plugins. +`polars-techr` exposes `techr` indicators as Polars expression plugins. + +## Installation + +```bash +uv add polars-techr +``` ## Supported indicators @@ -46,3 +52,29 @@ result = df.select( - `ichimoku_leading_span_b` uses `period` for the rolling window and `base_line_period` for the forward displacement. The Python wrapper defaults `base_line_period` to `26`. - `ichimoku_lagging_span` uses `base_line_period` for its backward displacement. - Polars plugins keep the output row-aligned with the input, so `ichimoku_leading_span_a` and `ichimoku_leading_span_b` truncate the forward-projected tail from the core result. + +## Development + +```bash +cd polars +uv sync --group dev +uv run maturin develop --uv +uv run pytest +``` + +Build distributable artifacts locally with: + +```bash +cd polars +uv run maturin build --release --sdist --out dist +uv run python scripts/check_artifacts.py dist +``` + +## Release + +1. Update the version in `Cargo.toml`. +2. Optionally build release artifacts locally for a final preflight check. +3. Optionally run the `Polars Release` workflow manually to publish the current ref to TestPyPI. +4. Create and push a `polars-vX.Y.Z` tag to publish to PyPI. + +Before the first release, configure Trusted Publishers for both PyPI and TestPyPI on `alphaprime-dev/techr`. diff --git a/polars/polars_techr/__init__.py b/polars/polars_techr/__init__.py index 9287dd7..5113d5d 100644 --- a/polars/polars_techr/__init__.py +++ b/polars/polars_techr/__init__.py @@ -4,9 +4,9 @@ import polars as pl from polars.plugins import register_plugin_function -from polars_techr.types import IntoExpr +from .types import IntoExpr -LIB = Path(__file__).parent +LIB = Path(__file__).resolve().parent __all__ = [ "bband_lower", diff --git a/polars/pyproject.toml b/polars/pyproject.toml index 15a6e1f..a23e776 100644 --- a/polars/pyproject.toml +++ b/polars/pyproject.toml @@ -1,12 +1,28 @@ [project] name = "polars-techr" -version = "0.1.0" -description = "Python wrapper for polars-techr" +dynamic = ["version"] +description = "Polars expression plugins for techr indicators" authors = [{ name = "alphaprime-dev" }] requires-python = ">=3.10,<3.14" readme = "README.md" -license = "MIT" -dependencies = ["polars>=1.26.0"] +license = { text = "MIT" } +dependencies = ["polars>=1.39,<2"] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", + "Topic :: Scientific/Engineering", +] + +[project.urls] +Homepage = "https://github.com/alphaprime-dev/techr" +Repository = "https://github.com/alphaprime-dev/techr" +Issues = "https://github.com/alphaprime-dev/techr/issues" [dependency-groups] dev = [ @@ -21,7 +37,16 @@ default-groups = "all" [tool.maturin] module-name = "polars_techr._polars_techr" +python-packages = ["polars_techr"] +strip = true +exclude = [ + ".pytest_cache/**", + ".ruff_cache/**", + ".venv/**", + "polars_techr/__pycache__/**", + "tests/__pycache__/**", +] [build-system] -requires = ["maturin>=1.0,<2.0", "polars>=1.7.1"] +requires = ["maturin>=1.7.1,<2"] build-backend = "maturin" diff --git a/polars/scripts/check_artifacts.py b/polars/scripts/check_artifacts.py new file mode 100644 index 0000000..398fe82 --- /dev/null +++ b/polars/scripts/check_artifacts.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import argparse +import tarfile +import zipfile +from pathlib import Path, PurePosixPath + + +BANNED_PARTS = { + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".venv", +} +BANNED_SUFFIXES = {".pyc", ".pyo"} +REQUIRED_WHEEL_FILES = { + PurePosixPath("polars_techr/__init__.py"), + PurePosixPath("polars_techr/types.py"), +} +REQUIRED_SDIST_FILES = { + PurePosixPath("pyproject.toml"), + PurePosixPath("Cargo.toml"), + PurePosixPath("README.md"), + PurePosixPath("polars_techr/__init__.py"), +} + + +def load_members(path: Path) -> list[PurePosixPath]: + if path.suffix == ".whl": + with zipfile.ZipFile(path) as archive: + return [PurePosixPath(name) for name in archive.namelist()] + if path.suffixes[-2:] == [".tar", ".gz"]: + with tarfile.open(path, "r:gz") as archive: + return [PurePosixPath(name) for name in archive.getnames()] + raise ValueError(f"Unsupported artifact format: {path}") + + +def strip_root(paths: list[PurePosixPath]) -> list[PurePosixPath]: + stripped: list[PurePosixPath] = [] + for path in paths: + if len(path.parts) <= 1: + continue + stripped.append(PurePosixPath(*path.parts[1:])) + return stripped + + +def has_suffix_path(path: PurePosixPath, suffix: tuple[str, ...]) -> bool: + return len(path.parts) >= len(suffix) and path.parts[-len(suffix) :] == suffix + + +def assert_no_banned_entries(path: Path, members: list[PurePosixPath]) -> None: + for member in members: + if any(part in BANNED_PARTS for part in member.parts): + raise ValueError(f"{path.name} contains banned path: {member.as_posix()}") + if member.suffix in BANNED_SUFFIXES: + raise ValueError(f"{path.name} contains banned file: {member.as_posix()}") + + +def validate_wheel(path: Path, members: list[PurePosixPath]) -> None: + required_missing = sorted( + file.as_posix() for file in REQUIRED_WHEEL_FILES if file not in members + ) + if required_missing: + raise ValueError(f"{path.name} is missing files: {', '.join(required_missing)}") + + native_extensions = [ + member + for member in members + if member.parent == PurePosixPath("polars_techr") + and member.name.startswith("_polars_techr.") + and member.suffix in {".so", ".pyd"} + ] + if len(native_extensions) != 1: + found = ", ".join(member.as_posix() for member in native_extensions) or "none" + raise ValueError( + f"{path.name} must contain exactly one native extension, found {found}" + ) + + +def validate_sdist(path: Path, members: list[PurePosixPath]) -> None: + normalized = strip_root(members) + missing_files = sorted( + file.as_posix() for file in REQUIRED_SDIST_FILES if file not in normalized + ) + if missing_files: + raise ValueError(f"{path.name} is missing files: {', '.join(missing_files)}") + + required_patterns = { + "polars_techr/*.py": any( + member.suffix == ".py" + and len(member.parts) >= 2 + and member.parts[-2] == "polars_techr" + for member in normalized + ), + "src/*.rs": any( + member.suffix == ".rs" + and has_suffix_path(member.parent, ("src",)) + for member in normalized + ), + } + missing_patterns = sorted( + pattern for pattern, matched in required_patterns.items() if not matched + ) + if missing_patterns: + raise ValueError( + f"{path.name} is missing required source patterns: {', '.join(missing_patterns)}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("artifact_dir", type=Path) + args = parser.parse_args() + + artifact_dir = args.artifact_dir.resolve() + artifacts = sorted(artifact_dir.glob("*.whl")) + sorted(artifact_dir.glob("*.tar.gz")) + if not artifacts: + raise SystemExit(f"No artifacts found in {artifact_dir}") + + for artifact in artifacts: + members = load_members(artifact) + assert_no_banned_entries(artifact, members) + if artifact.suffix == ".whl": + validate_wheel(artifact, members) + else: + validate_sdist(artifact, members) + print(f"checked {artifact.name}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/polars/scripts/smoke_import.py b/polars/scripts/smoke_import.py new file mode 100644 index 0000000..1d9643f --- /dev/null +++ b/polars/scripts/smoke_import.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + +import polars as pl +import polars_techr as ta + + +def main() -> int: + expr = ta.sma(pl.col("x"), period=2) + if not isinstance(expr, pl.Expr): + raise TypeError("polars-techr did not return a Polars expression") + + print(f"loaded {Path(ta.__file__).resolve()}") + print(expr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/polars/uv.lock b/polars/uv.lock index d976286..f65d594 100644 --- a/polars/uv.lock +++ b/polars/uv.lock @@ -100,7 +100,6 @@ wheels = [ [[package]] name = "polars-techr" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "polars" }, @@ -115,7 +114,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "polars", specifier = ">=1.26.0" }] +requires-dist = [{ name = "polars", specifier = ">=1.39,<2" }] [package.metadata.requires-dev] dev = [