name: Release - Python on: workflow_dispatch: inputs: version: description: "Version to release (e.g. 2.1.0)" required: true type: string dry-run: description: "Dry run (build but do not publish)" type: boolean default: false permissions: contents: write id-token: write env: CARGO_TERM_COLOR: always jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Validate version run: | PYPROJECT_VERSION=$(python3 -c " import re with open('packages/python/pyproject.toml') as f: match = re.search(r'^version\s*=\s*\"(.+?)\"', f.read(), re.MULTILINE) print(match.group(1)) ") if [ "$PYPROJECT_VERSION" != "${{ inputs.version }}" ]; then echo "::error::Version mismatch: pyproject.toml has $PYPROJECT_VERSION, expected ${{ inputs.version }}" exit 1 fi echo "Version validated: ${{ inputs.version }}" build-wheels: needs: validate strategy: fail-fast: false matrix: include: - os: macos-latest target: aarch64-apple-darwin - os: macos-26-intel target: x86_64-apple-darwin - os: ubuntu-latest target: x86_64-unknown-linux-gnu - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - os: ubuntu-latest target: x86_64-unknown-linux-musl musl: true - os: windows-latest target: x86_64-pc-windows-msvc - os: windows-11-arm target: aarch64-pc-windows-msvc runs-on: ${{ matrix.os }} name: Build ${{ matrix.target }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - uses: dtolnay/rust-toolchain@1.95.0 with: targets: ${{ matrix.target }} - name: Install system dependencies (Linux) if: runner.os == 'Linux' && !matrix.musl run: | sudo apt-get update sudo apt-get install -y libtesseract-dev libleptonica-dev - name: Install system dependencies (macOS) if: runner.os == 'macOS' run: brew install tesseract leptonica - name: Force Ninja generator (Windows) if: runner.os == 'Windows' run: echo "CMAKE_GENERATOR=Ninja" >> $GITHUB_ENV shell: bash - uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git ~/.cache/pdfium-rs ~/Library/Caches/pdfium-rs ${{ runner.os == 'Windows' && format('{0}\pdfium-rs', env.LOCALAPPDATA) || '' }} target key: ${{ runner.os }}-${{ matrix.target }}-cargo-python-${{ hashFiles('**/Cargo.lock') }} - name: Stage pdfium for wheel inclusion if: ${{ !matrix.musl }} shell: bash # Pre-download pdfium and place it at packages/python/liteparse/ # so the `[tool.maturin] include` rule packs it into the wheel during # the build below. This is what gets the dylib listed in the wheel's # RECORD manifest — appending it after the build leaves RECORD out of # sync, which Poetry warns about. run: bash packages/python/scripts/download-pdfium.sh ${{ matrix.target }} - name: Build wheels (glibc) if: ${{ !matrix.musl }} uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} args: --release --out dist --find-interpreter manylinux: 2_28 working-directory: packages/python before-script-linux: | if command -v yum &> /dev/null; then yum install -y tesseract-devel leptonica-devel openssl-devel pkgconfig perl-IPC-Cmd || true elif command -v apt-get &> /dev/null; then apt-get update && apt-get install -y libtesseract-dev libleptonica-dev libssl-dev pkg-config || true elif command -v apk &> /dev/null; then apk add tesseract-ocr-dev leptonica-dev openssl-dev pkgconf || true fi - name: Build wheels (musl container) if: ${{ matrix.musl }} # Same pattern as the node musl build: run inside a vanilla # python:3-alpine with clang+libc++ from apk. Bypasses maturin-action's # musllinux_1_2 cross image, which causes FORTIFY_SOURCE issues with # tesseract's C++ build and broken host-openssl for build-deps. run: | chmod +x scripts/build-musl-py.sh docker run --rm \ -v "$GITHUB_WORKSPACE":/work \ -v "$HOME/.cargo/registry":/root/.cargo/registry \ -v "$HOME/.cargo/git":/root/.cargo/git \ -v "$HOME/.cache/pdfium-rs":/root/.cache/pdfium-rs \ -w /work \ python:3.12-alpine \ /work/scripts/build-musl-py.sh # The container runs as root; fix perms so subsequent steps can # read/upload the produced wheel. sudo chmod -R a+rw packages/python/dist/ || true - name: Upload wheels uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.target }} path: packages/python/dist/*.whl if-no-files-found: error build-sdist: needs: validate runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build sdist uses: PyO3/maturin-action@v1 with: command: sdist args: --out dist working-directory: packages/python - name: Upload sdist uses: actions/upload-artifact@v4 with: name: sdist path: packages/python/dist/*.tar.gz if-no-files-found: error test-wheels: needs: build-wheels strategy: fail-fast: false matrix: include: - os: macos-latest target: aarch64-apple-darwin - os: macos-26-intel target: x86_64-apple-darwin - os: ubuntu-latest target: x86_64-unknown-linux-gnu - os: windows-latest target: x86_64-pc-windows-msvc - os: windows-11-arm target: aarch64-pc-windows-msvc runs-on: ${{ matrix.os }} name: Test ${{ matrix.target }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Download wheels uses: actions/download-artifact@v4 with: name: wheels-${{ matrix.target }} path: dist - name: Install wheel run: pip install --no-index --find-links dist liteparse - name: Smoke test run: | python -c " from liteparse import LiteParse, ParseResult, ParsedPage, TextItem, ScreenshotResult parser = LiteParse(ocr_enabled=False, quiet=True) print('LiteParse loaded successfully') print(repr(parser)) " - name: Parse test run: | python -c " from liteparse import LiteParse parser = LiteParse(ocr_enabled=False, quiet=True, max_pages=2) result = parser.parse('demo/docs/apple-10k-2024.pdf') assert result.num_pages == 2, f'Expected 2 pages, got {result.num_pages}' assert len(result.text) > 0, 'Expected non-empty text' page = result.get_page(1) assert page is not None, 'Expected page 1' assert len(page.text_items) > 0, 'Expected text items' print(f'Parsed {result.num_pages} pages, {len(result.text)} chars') " - name: Bytes parse test run: | python -c " from liteparse import LiteParse parser = LiteParse(ocr_enabled=False, quiet=True, max_pages=1) with open('demo/docs/apple-10k-2024.pdf', 'rb') as f: data = f.read() result = parser.parse(data) assert result.num_pages == 1 assert len(result.text) > 0 print('Bytes parse test passed') " - name: Screenshot test run: | python -c " from liteparse import LiteParse parser = LiteParse(ocr_enabled=False, quiet=True) screenshots = parser.screenshot('demo/docs/apple-10k-2024.pdf', page_numbers=[1]) assert len(screenshots) == 1 assert screenshots[0].page_num == 1 assert len(screenshots[0].image_bytes) > 0 print(f'Screenshot: {screenshots[0].width}x{screenshots[0].height}, {len(screenshots[0].image_bytes)} bytes') " - name: CLI test run: | lit --version lit parse demo/docs/apple-10k-2024.pdf --no-ocr --max-pages 1 -q | head -5 echo "CLI test passed" build-cli: needs: validate strategy: matrix: include: - target: x86_64-unknown-linux-gnu os: ubuntu-22.04 artifact: lit-linux-x64 - target: aarch64-unknown-linux-gnu os: ubuntu-22.04-arm artifact: lit-linux-arm64 - target: aarch64-apple-darwin os: macos-latest artifact: lit-darwin-arm64 - target: x86_64-apple-darwin os: macos-26-intel artifact: lit-darwin-x64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.95.0 with: targets: ${{ matrix.target }} - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libtesseract-dev libleptonica-dev - name: Install system dependencies (macOS) if: runner.os == 'macOS' run: brew install tesseract leptonica - uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git ~/.cache/pdfium-rs ~/Library/Caches/pdfium-rs target key: ${{ runner.os }}-${{ matrix.target }}-cargo-cli-${{ hashFiles('**/Cargo.lock') }} - name: Build run: cargo build --release --target ${{ matrix.target }} -p liteparse - name: Package binary run: | mkdir -p dist cp target/${{ matrix.target }}/release/lit dist/${{ matrix.artifact }} chmod +x dist/${{ matrix.artifact }} tar -czf dist/${{ matrix.artifact }}.tar.gz -C dist ${{ matrix.artifact }} - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} path: dist/${{ matrix.artifact }}.tar.gz publish: needs: [test-wheels, build-sdist, build-cli] runs-on: ubuntu-latest name: Publish & Release steps: - uses: actions/checkout@v4 - name: Download all wheels uses: actions/download-artifact@v4 with: pattern: wheels-* path: dist merge-multiple: true - name: Download sdist uses: actions/download-artifact@v4 with: name: sdist path: dist - name: Download CLI artifacts uses: actions/download-artifact@v4 with: pattern: lit-* path: cli-artifacts - name: Publish to PyPI if: ${{ !inputs.dry-run }} uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ password: ${{ secrets.PYPI_TOKEN }} skip-existing: true - name: Create git tag if: ${{ !inputs.dry-run }} run: | git tag "python-v${{ inputs.version }}" git push origin "python-v${{ inputs.version }}" - name: Create GitHub Release if: ${{ !inputs.dry-run }} uses: softprops/action-gh-release@v2 with: tag_name: python-v${{ inputs.version }} name: "Python v${{ inputs.version }}" generate_release_notes: true files: cli-artifacts/**/*.tar.gz - name: Summary (dry run) if: ${{ inputs.dry-run }} run: | echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY echo "Would release python-v${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY echo "### Wheels:" >> $GITHUB_STEP_SUMMARY find dist -name "*.whl" -exec echo "- {}" \; >> $GITHUB_STEP_SUMMARY echo "### CLI artifacts:" >> $GITHUB_STEP_SUMMARY find cli-artifacts -name "*.tar.gz" -exec echo "- {}" \; >> $GITHUB_STEP_SUMMARY