From 837b1b08b7e5e4ead49d852dc8ec3fba0e4384d7 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 10:44:13 +0300 Subject: [PATCH] Move to trunk-based releases: build on main, publish from a GitHub Release - build.yml (replaces main.yml): tests on Python 3.10-3.13 with pytest, then builds and checks the sdist and wheel as a 0.0.0.devN+gSHA artifact; the `build` job is the required check. - publish.yml: a GitHub Release v{semver} published by tomasr78 builds the tag with the tag's version, tests the installed wheel and uploads to PyPI with Trusted Publishing (same file and pypi environment, so the existing trusted publisher still matches). Never re-uploads an existing version. - Packaging moved to pyproject.toml; __version__ is a placeholder written from the tag. - Tests ported from nose to pytest; new mocked tests run without network, the old ones are `live`. - Actions pinned to SHAs, upload-artifact v7, weekly Dependabot for actions. - RELEASING.md and CHANGELOG.md. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 10 ++ .github/workflows/build.yml | 128 ++++++++++++++++ .github/workflows/main.yml | 25 --- .github/workflows/publish.yml | 280 +++++++++++++++++++++++++++++----- .gitignore | 2 + CHANGELOG.md | 30 ++++ README.md | 19 ++- RELEASING.md | 45 ++++++ convertapi/__init__.py | 3 +- pyproject.toml | 45 ++++++ requirements.txt | 1 - setup.cfg | 7 - setup.py | 20 --- test-requirements.txt | 5 +- tests/conftest.py | 28 ++++ tests/test_client.py | 132 ++++++++++++++++ tests/test_convertapi.py | 134 ++++++++-------- tests/utils.py | 4 - 18 files changed, 740 insertions(+), 178 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/main.yml create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py delete mode 100644 tests/utils.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..abb6ea3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + # The workflows pin every action to a commit SHA; Dependabot keeps those pins current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ['*'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1d3e6d2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,128 @@ +name: Build +run-name: >- + ${{ github.event_name == 'pull_request' && format('Checking that #{0} builds', github.event.pull_request.number) + || format('Building {0}', github.ref_name) }} + +# Trunk-based: main is the only long-lived branch. Every pull request and every push to main runs the tests +# on each supported Python and builds the sdist and wheel, but nothing is published here: this is a public +# PyPI package, and PyPI versions are permanent, so a release is a deliberate act - publishing a GitHub +# Release v{semver}, which runs publish.yml. The required status check is the `build` job, which passes +# only when every test job passed and the packages built. +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # A newer push to the same pull request cancels the older build; builds of main never cancel each other. + group: build-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: test (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python }} + + - name: Install + run: python -m pip install . -r test-requirements.txt + + # The tests marked `live` convert real files on ConvertAPI and run only when the CONVERTAPI_SECRET + # secret (an API token) exists; pull requests from forks and Dependabot never get it, so for them + # only the mocked tests run. CONVERTAPI_BASE_URI (a variable) can point them at another deployment. + - name: Test + env: + CONVERT_API_SECRET: ${{ secrets.CONVERTAPI_SECRET }} + CONVERT_API_BASE_URI: ${{ vars.CONVERTAPI_BASE_URI }} + run: python -m pytest -ra + + build: + name: build + needs: test + # Runs even when a test job failed, so that the required check reports a failure instead of being + # skipped - a skipped required check counts as passed. + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check the tests + env: + RESULT: ${{ needs.test.result }} + run: | + if [ "$RESULT" != success ]; then + echo "::error::The tests did not pass ($RESULT)" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Install the build tools + run: python -m pip install build==1.6.1 twine==7.0.0 + + # A development version that sorts below any real release and names the run, so a package + # downloaded from this run can be tried out but never mistaken for a published one. PyPI refuses + # local versions ("+g..."), so it could not be uploaded even by mistake. + - name: Build + id: build + env: + RUN: ${{ github.run_number }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + VERSION="0.0.0.dev$RUN+g${SHA:0:7}" + sed -i "s/^__version__ = .*/__version__ = '$VERSION'/" convertapi/__init__.py + grep -qx "__version__ = '$VERSION'" convertapi/__init__.py + python -m build -o dist + twine check --strict dist/* + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check the wheel installs + env: + VERSION: ${{ steps.build.outputs.version }} + run: | + set -euo pipefail + python -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install dist/*.whl + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/venv/bin/python" -c 'import sys, convertapi; assert convertapi.__version__ == sys.argv[1], convertapi.__version__; print(convertapi.user_agent)' "$VERSION" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: convertapi-${{ steps.build.outputs.version }} + path: dist/ + retention-days: 14 + if-no-files-found: error + + - name: Summary + env: + VERSION: ${{ steps.build.outputs.version }} + run: | + { + echo "| | |" + echo "|---|---|" + echo "| Package | \`convertapi $VERSION\` (artifact only, not published) |" + echo "| Files | $(ls dist | paste -sd ',' - | sed 's/,/, /g') |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 166112e..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Build -on: [push,pull_request] -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: - - '3.7' - - '3.9' - name: Python ${{ matrix.python-version }} sample - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r test-requirements.txt - - env: - CONVERT_API_SECRET: ${{ secrets.CONVERTAPI_SECRET }} - run: nosetests diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 791962a..af0ef0f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,51 +1,255 @@ -name: Publish Python 🐍 distribution 📦 to PyPI +name: Publish +run-name: Publishing convertapi ${{ github.event.release.tag_name }} to PyPI +# A release is a GitHub Release, never a merge. Merging to main only builds (build.yml); publishing the +# Release v{semver} runs this file, which builds that tag's commit with the tag's version, tests it and +# uploads it to PyPI. PyPI versions are public and permanent - a file name can never be uploaded again, +# even after the release is deleted - so nothing here retries, overwrites or skips an existing version. +# +# gh release create v2.0.1 --target main --generate-notes -> convertapi 2.0.1 +# gh release create v2.1.0-rc.1 --target main --prerelease ... -> convertapi 2.1.0rc1 +# +# A stable version must be a commit on main. A pre-release may be any commit, so a branch can be tried +# out as a pre-release package before it is merged. +# +# PyPI Trusted Publishing trusts this file by name (publish.yml) and the pypi environment, so neither may +# be renamed without changing the trusted publisher on https://pypi.org/manage/project/convertapi/settings/publishing/ on: release: types: [published] +permissions: + contents: read + +env: + # Only this account may publish a Release that reaches PyPI. GitHub cannot express this as a permission + # on the Team plan, so it is checked here (in both jobs, as either can be re-run on its own); the real + # controls are the v* tag ruleset (only repository admins create v* tags) and the pypi environment, + # which accepts only v* tags. + PUBLISHER: tomasr78 + PACKAGE: convertapi + +concurrency: + group: publish-pypi + cancel-in-progress: false + jobs: - build: - name: Build distribution 📦 + package: + name: package runs-on: ubuntu-latest - + timeout-minutes: 20 + outputs: + version: ${{ steps.tag.outputs.version }} + sha: ${{ steps.tag.outputs.sha }} steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v3 - with: - name: python-package-distributions - path: dist/ - - publish-to-pypi: - name: >- - Publish Python 🐍 distribution 📦 to PyPI - needs: - - build + - name: Check who published the release + run: | + # A re-run keeps GITHUB_ACTOR as the original user; GITHUB_TRIGGERING_ACTOR is whoever pressed + # "Re-run", so both must be the publisher or anyone with write access could replay a release. + if [ "$GITHUB_ACTOR" != "$PUBLISHER" ] || [ "${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}" != "$PUBLISHER" ]; then + echo "::error::Only $PUBLISHER may publish to PyPI - this release was published by $GITHUB_ACTOR and run by ${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}." + exit 1 + fi + echo "Published by $GITHUB_ACTOR" + + # The release event runs this file from the tag, so the workflow and the code come from the same commit. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check the tag and the version + id: tag + env: + TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + set -euo pipefail + # SemVer 2.0 without build metadata; PyPI needs a PEP 440 version, so the only pre-release + # labels are alpha.N, beta.N and rc.N, which become aN, bN and rcN (v2.1.0-rc.1 -> 2.1.0rc1). + SEMVER='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(alpha|beta|rc)\.(0|[1-9][0-9]*))?$' + if ! [[ "$TAG" =~ $SEMVER ]]; then + echo "::error::Tag '$TAG' is not v{major}.{minor}.{patch} or v{major}.{minor}.{patch}-{alpha|beta|rc}.{n}, e.g. v2.0.1 or v2.1.0-rc.1" + exit 1 + fi + VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + PRE="${BASH_REMATCH[4]}" + case "${BASH_REMATCH[5]}" in + alpha) VERSION="${VERSION}a${BASH_REMATCH[6]}" ;; + beta) VERSION="${VERSION}b${BASH_REMATCH[6]}" ;; + rc) VERSION="${VERSION}rc${BASH_REMATCH[6]}" ;; + esac + if [ "$GITHUB_REF" != "refs/tags/$TAG" ]; then + echo "::error::The run is on $GITHUB_REF, not on the release tag refs/tags/$TAG" + exit 1 + fi + SHA=$(git rev-parse "refs/tags/$TAG^{commit}") + if [ "$(git rev-parse HEAD)" != "$SHA" ]; then + echo "::error::Checked out $(git rev-parse HEAD), but $TAG points at $SHA" + exit 1 + fi + + # The GitHub "pre-release" tick and the version must agree, so a pre-release cannot become the + # package's latest stable version by a forgotten tick, and a stable version cannot hide as a pre-release. + if [ -n "$PRE" ]; then + if [ "$PRERELEASE" != true ]; then + echo "::error::$TAG is a pre-release version, so the GitHub Release must be marked as a pre-release (gh release create --prerelease)" + exit 1 + fi + else + if [ "$PRERELEASE" = true ]; then + echo "::error::$TAG is a stable version, but the GitHub Release is marked as a pre-release. Use a pre-release version such as ${TAG}-rc.1, or untick pre-release." + exit 1 + fi + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::$TAG ($SHA) is not on main. Stable versions are released from main only; release a branch as a pre-release instead." + exit 1 + fi + fi + + # PyPI answers 200 for every version it has, yanked ones included, and 404 for one it never had. + # (A deleted version answers 404 too, but PyPI then refuses its file names, so the upload fails.) + STATUS=$(curl -sS --retry 3 -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/$PACKAGE/$VERSION/json") + case "$STATUS" in + 404) ;; + 200) echo "::error::$PACKAGE $VERSION already exists on PyPI and can never be uploaded again. Release the next version instead."; exit 1 ;; + *) echo "::error::Could not ask PyPI whether $PACKAGE $VERSION exists (HTTP $STATUS)"; exit 1 ;; + esac + if [ -z "$PRE" ]; then + LATEST=$(curl -fsS --retry 3 "https://pypi.org/pypi/$PACKAGE/json" | jq -r '.info.version') + if [ "$(printf '%s\n%s\n' "$LATEST" "$VERSION" | sort -V | tail -n1)" != "$VERSION" ]; then + echo "::warning::$VERSION is lower than the latest version $LATEST, so it will not become the version that new installs get." + fi + fi + + { + echo "version=$VERSION" + echo "sha=$SHA" + } >> "$GITHUB_OUTPUT" + echo "Publishing $PACKAGE $VERSION from $SHA" + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Install the build and test tools + run: python -m pip install build==1.6.1 twine==7.0.0 -r test-requirements.txt + + - name: Set the version + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + sed -i "s/^__version__ = .*/__version__ = '$VERSION'/" convertapi/__init__.py + grep -qx "__version__ = '$VERSION'" convertapi/__init__.py + + - name: Build + run: | + set -euo pipefail + python -m build -o dist + twine check --strict dist/* + ls -l dist + + - name: Check the packages + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + test -f "dist/$PACKAGE-$VERSION.tar.gz" + test -f "dist/$PACKAGE-$VERSION-py3-none-any.whl" + test "$(ls dist | wc -l)" -eq 2 + unzip -p "dist/$PACKAGE-$VERSION-py3-none-any.whl" "$PACKAGE-$VERSION.dist-info/METADATA" | grep -qx "Version: $VERSION" + + # The tests run against the built wheel, installed outside the checkout, so they see what users get. + # The `live` tests run too when the CONVERTAPI_SECRET secret exists. + - name: Test the wheel + env: + VERSION: ${{ steps.tag.outputs.version }} + CONVERT_API_SECRET: ${{ secrets.CONVERTAPI_SECRET }} + CONVERT_API_BASE_URI: ${{ vars.CONVERTAPI_BASE_URI }} + run: | + set -euo pipefail + python -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install "dist/$PACKAGE-$VERSION-py3-none-any.whl" -r test-requirements.txt + mv convertapi "$RUNNER_TEMP/convertapi-source" + "$RUNNER_TEMP/venv/bin/python" -c 'import sys, convertapi; assert convertapi.__version__ == sys.argv[1], convertapi.__version__; assert "site-packages" in convertapi.__file__, convertapi.__file__' "$VERSION" + "$RUNNER_TEMP/venv/bin/python" -m pytest -ra + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + retention-days: 90 + if-no-files-found: error + + publish: + name: publish + needs: package runs-on: ubuntu-latest + timeout-minutes: 30 environment: name: pypi - url: https://pypi.org/p/convertapi + url: https://pypi.org/project/convertapi/${{ needs.package.outputs.version }}/ permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing - + contents: read + # Only for PyPI Trusted Publishing, which trades this job's OIDC token for a short-lived upload + # token. Nothing in this job runs the repository's code. + id-token: write steps: - - name: Download all the dists - uses: actions/download-artifact@v3 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Check who published the release + run: | + if [ "$GITHUB_ACTOR" != "$PUBLISHER" ] || [ "${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}" != "$PUBLISHER" ]; then + echo "::error::Only $PUBLISHER may publish to PyPI - this release was published by $GITHUB_ACTOR and run by ${GITHUB_TRIGGERING_ACTOR:-$GITHUB_ACTOR}." + exit 1 + fi + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + + - name: Check the packages again + env: + VERSION: ${{ needs.package.outputs.version }} + run: | + set -euo pipefail + ls -l dist + test -f "dist/$PACKAGE-$VERSION.tar.gz" + test -f "dist/$PACKAGE-$VERSION-py3-none-any.whl" + test "$(ls dist | wc -l)" -eq 2 + + # Trusted Publishing (no stored token) with PEP 740 attestations. skip-existing stays off: a version + # that already exists must fail loudly, never pass as published. + - name: Upload to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ + + # PyPI serves an upload within seconds, but its CDN can lag. A slow index is not a failed release, + # so this only warns. + - name: Wait for PyPI to serve the package + env: + VERSION: ${{ needs.package.outputs.version }} + run: | + set -uo pipefail + for _ in $(seq 1 20); do + if curl -fsS -o /dev/null "https://pypi.org/pypi/$PACKAGE/$VERSION/json"; then + echo "$PACKAGE $VERSION is available on PyPI" + exit 0 + fi + sleep 30 + done + echo "::warning::$PACKAGE $VERSION was uploaded but PyPI has not served it after 10 minutes. Check https://pypi.org/project/$PACKAGE/$VERSION/" + + - name: Summary + env: + VERSION: ${{ needs.package.outputs.version }} + SHA: ${{ needs.package.outputs.sha }} + run: | + { + echo "| | |" + echo "|---|---|" + echo "| Package | [\`$PACKAGE $VERSION\`](https://pypi.org/project/$PACKAGE/$VERSION/) |" + echo "| Commit | \`$SHA\` |" + echo "| Published with | Trusted Publishing |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 8999207..0055705 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ coverage.xml docs/_build/ .python-version + +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..484d821 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog and this project adheres to Semantic Versioning. + +## [2.0.1] — Unreleased + +### Changed +- Packaging moved from `setup.py` to `pyproject.toml`. The PyPI page now shows this README and links to + the source, changelog and issues; the wheel is `py3-none-any` (it was tagged `py2.py3`, although + Python 2 has not been supported since 1.7.0). No change to the library's API or dependencies. +- Tested on Python 3.10–3.13 (was 3.7 and 3.9), with pytest instead of nose. The tests run without network + access; the ones that call the real service are marked `live`. +- Releases are now cut from a GitHub Release `v{version}`; the package version comes from the tag and is + uploaded with PyPI Trusted Publishing. See [RELEASING.md](RELEASING.md). + +## [2.0.0] — 2024-09-14 + +### Changed +- Credentials are sent in the `Authorization` header; `api_secret`, `api_token` and `api_key` were + replaced by `api_credentials` (an API secret or token). +- Upload and download timeouts default to `None` (no timeout). + +### Removed +- Alternative converter support. + +## Earlier versions + +See the [GitHub Releases](https://github.com/ConvertAPI/convertapi-library-python/releases) for 1.8.0 and older. diff --git a/README.md b/README.md index 28204fe..30ee95f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ConvertAPI Python Client [![PyPI version](https://badge.fury.io/py/convertapi.svg)](https://badge.fury.io/py/convertapi) -[![Build Status](https://github.com/ConvertAPI/convertapi-library-python/actions/workflows/main.yml/badge.svg)](https://github.com/ConvertAPI/convertapi-library-python/actions) +[![Build Status](https://github.com/ConvertAPI/convertapi-library-python/actions/workflows/build.yml/badge.svg)](https://github.com/ConvertAPI/convertapi-library-python/actions/workflows/build.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) ## Convert your files with our online file conversion API @@ -16,11 +16,11 @@ Install with [pip](https://pypi.org/project/pip/): Install from source with: - python setup.py install + pip install . ### Requirements -* Python 3.3+ +* Python 3.10+ (tested on 3.10–3.13) ## Usage @@ -119,11 +119,18 @@ convertapi.base_uri = 'https://eu-v2.convertapi.com/' ### More examples -Find more advanced examples in the [/examples](https://github.com/ConvertAPI/convertapi-library-python/tree/master/examples) folder. +Find more advanced examples in the [/examples](https://github.com/ConvertAPI/convertapi-library-python/tree/main/examples) folder. ## Development -Execute `API_TOKEN=api-token nosetests --nocapture` to run the tests. +```bash +pip install -e . -r test-requirements.txt +pytest +``` + +Tests marked `live` call the real service and are skipped unless `CONVERT_API_SECRET` holds an API token +(`CONVERT_API_BASE_URI` optionally points them at another deployment). Releases are described in +[RELEASING.md](https://github.com/ConvertAPI/convertapi-library-python/blob/main/RELEASING.md). ## Contributing @@ -131,4 +138,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/Conver ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +The library is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..ffbeb52 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,45 @@ +# Releasing + +`main` is the only long-lived branch. Changes reach it through pull requests, merge commits only, and +every pull request and push to `main` must pass the `build` check (`.github/workflows/build.yml`: the +tests on Python 3.10–3.13, then build and check the sdist and wheel). Merging publishes nothing. + +A release is a **GitHub Release** whose tag is the version, published by `tomasr78`: + +```bash +gh release create v2.0.1 --repo ConvertAPI/convertapi-library-python --target main --title v2.0.1 --generate-notes +``` + +That runs `.github/workflows/publish.yml`, which: + +1. refuses a Release published or re-run by anyone else; +2. checks the tag (`v2.0.1`, `v2.1.0-rc.1`), that the GitHub pre-release tick matches the version, that a + stable version is a commit on `main`, and that PyPI has never had that version; +3. writes the version into `convertapi/__init__.py`, builds the sdist and wheel, and runs the tests + against the installed wheel (the `live` tests too, when the `CONVERTAPI_SECRET` secret exists); +4. uploads both files to PyPI from the `pypi` environment with Trusted Publishing (no stored token, PEP 740 + attestations) and waits until PyPI serves them. + +**Pre-releases** work the same way and may come from any branch. PyPI needs PEP 440 versions, so the +pre-release label is `alpha.N`, `beta.N` or `rc.N` (`v2.1.0-rc.1` becomes `2.1.0rc1`): + +```bash +gh release create v2.1.0-rc.1 --repo ConvertAPI/convertapi-library-python --target my-branch --prerelease --title v2.1.0-rc.1 --notes "Release candidate for ..." +``` + +PyPI versions are permanent: a file name can never be uploaded again, even after the release is deleted. +A bad version can only be yanked on PyPI, then fixed by releasing the next version. Never delete and +re-create a release tag. + +`__version__` in `convertapi/__init__.py` is a `0.0.0.dev0` placeholder; pull request builds produce +`0.0.0.dev{run}+g{sha}` as a downloadable artifact so a change can be tried before it is released. +Update `CHANGELOG.md` in the pull request that changes behaviour. + +## Guard rails + +- Rulesets: `main` takes pull requests with merge commits and a passing `build` (admins can bypass); + only repository admins create, move or delete `v*` tags, and a `v*` tag must match the pattern above. +- The `pypi` environment accepts deployments from `v*` tags only. +- PyPI trusts `ConvertAPI/convertapi-library-python`, workflow `publish.yml`, environment `pypi` + (https://pypi.org/manage/project/convertapi/settings/publishing/). Renaming the workflow file or the + environment breaks publishing until the trusted publisher is changed to match. diff --git a/convertapi/__init__.py b/convertapi/__init__.py index 1f5ce61..bdca559 100644 --- a/convertapi/__init__.py +++ b/convertapi/__init__.py @@ -1,4 +1,5 @@ -__version__ = '2.0.0' +# A placeholder: publish.yml writes the release tag's version here before building (see RELEASING.md). +__version__ = '0.0.0.dev0' from .exceptions import * from .client import Client diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2d6758f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "convertapi" +# The version is the release tag: publish.yml writes it into convertapi/__init__.py before building. +dynamic = ["version"] +description = "ConvertAPI Python Client" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE.txt"] +authors = [{ name = "Tomas Rutkauskas", email = "support@convertapi.com" }] +keywords = ["convert", "api", "client", "conversion"] +dependencies = ["requests>=2.4.2"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.urls] +Homepage = "https://www.convertapi.com" +Documentation = "https://www.convertapi.com/doc" +Source = "https://github.com/ConvertAPI/convertapi-library-python" +Changelog = "https://github.com/ConvertAPI/convertapi-library-python/blob/main/CHANGELOG.md" +Issues = "https://github.com/ConvertAPI/convertapi-library-python/issues" + +[tool.setuptools] +packages = ["convertapi"] + +[tool.setuptools.dynamic] +version = { attr = "convertapi.__version__" } + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "live: calls the real ConvertAPI service; skipped unless CONVERT_API_SECRET is set", +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f250822..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests>=1.2.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3d26e35..0000000 --- a/setup.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[metadata] -description-file = README.md - -[easy_install] - -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 9d0b508..0000000 --- a/setup.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python - -from setuptools.depends import get_module_constant -from setuptools import setup - -setup( - name = 'convertapi', - packages = ['convertapi'], - version = get_module_constant('convertapi', '__version__'), - description = 'Convert API Python Client', - long_description = 'Convert various files like MS Word, Excel, PowerPoint, Images to PDF and Images. Create PDF and Images from url and raw HTML. Extract and create PowerPoint presentation from PDF. Merge, Encrypt, Split, Repair and Decrypt PDF files. All supported files conversions and manipulations can be found at https://www.convertapi.com/doc/supported-formats', - author = 'Tomas Rutkauskas', - author_email = 'support@convertapi.com', - url = 'https://github.com/ConvertAPI/convertapi-library-python', - download_url = 'https://github.com/ConvertAPI/convertapi-library-python', - keywords = ['convert', 'api', 'client', 'conversion'], - install_requires= ['requests>=2.4.2'], - classifiers = [], - license = 'MIT', -) diff --git a/test-requirements.txt b/test-requirements.txt index 5ac06ed..54b1315 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,3 +1,2 @@ -nose -mock -responses +pytest>=8 +responses>=0.25 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6a5cfcb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +import os + +import pytest + +import convertapi + + +@pytest.fixture(autouse=True) +def configuration(): + """Give every test the default configuration and restore it afterwards.""" + saved = {name: getattr(convertapi, name) for name in ('api_credentials', 'base_uri', 'max_parallel_uploads')} + yield + for name, value in saved.items(): + setattr(convertapi, name, value) + + +@pytest.fixture +def live(): + """Credentials for tests that call the real service. + + CONVERT_API_SECRET is the API token. CONVERT_API_BASE_URI optionally points the tests at another + deployment (e.g. https://stag-v2.convertapi.com/); it defaults to the library's own base_uri. + """ + secret = os.environ.get('CONVERT_API_SECRET') + if not secret: + pytest.skip('CONVERT_API_SECRET is not set') + convertapi.api_credentials = secret + convertapi.base_uri = os.environ.get('CONVERT_API_BASE_URI') or convertapi.base_uri diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f75a079 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,132 @@ +"""Tests that need no network: the ConvertAPI service is mocked with `responses`.""" + +import io +import json +from urllib.parse import parse_qs + +import pytest +import responses + +import convertapi + +BASE = 'https://v2.convertapi.com/' + + +@pytest.fixture(autouse=True) +def credentials(): + convertapi.api_credentials = 'test-token' + + +def conversion(*files): + return { + 'ConversionCost': 1, + 'Files': [{ 'FileName': name, 'FileSize': 4, 'Url': 'https://v2.convertapi.com/d/' + name } for name in files], + } + + +def form(call): + return { k: v[0] for k, v in parse_qs(call.request.body).items() } + + +def test_defaults(): + assert convertapi.base_uri == BASE + assert convertapi.user_agent == 'ConvertAPI-Python/' + convertapi.__version__ + + +def test_configuration(): + convertapi.api_credentials = 'TEST' + assert convertapi.api_credentials == 'TEST' + + +@responses.activate +def test_user_sends_token_and_user_agent(): + responses.get(BASE + 'user', json={ 'Active': True }) + + assert convertapi.user()['Active'] + + headers = responses.calls[0].request.headers + assert headers['Authorization'] == 'Bearer test-token' + assert headers['User-Agent'] == convertapi.user_agent + + +@responses.activate +def test_convert_url(): + responses.post(BASE + 'convert/web/to/pdf', json=conversion('page.pdf')) + + result = convertapi.convert('pdf', { 'Url': 'https://example.com' }) + + assert result.conversion_cost == 1 + assert result.file.filename == 'page.pdf' + assert form(responses.calls[0]) == { 'Url': 'https://example.com', 'StoreFile': 'True' } + + +@responses.activate +def test_convert_with_format_and_timeout(): + responses.post(BASE + 'convert/web/to/pdf', json=conversion('page.pdf')) + + convertapi.convert('pdf', { 'Url': 'https://example.com/a.txt' }, 'web', 100) + + assert form(responses.calls[0])['Timeout'] == '100' + assert responses.calls[0].request.req_kwargs['timeout'] == 100 + convertapi.conversion_timeout_delta + + +@responses.activate +def test_convert_file_uploads_it_first(tmp_path): + responses.post(BASE + 'upload', json={ 'FileId': 'abc', 'FileExt': 'docx' }) + responses.post(BASE + 'convert/docx/to/pdf', json=conversion('test.pdf')) + responses.get('https://v2.convertapi.com/d/test.pdf', body=b'%PDF') + + result = convertapi.convert('pdf', { 'File': 'examples/files/test.docx' }) + saved = result.save_files(str(tmp_path)) + + upload = responses.calls[0].request + assert upload.headers['Content-Disposition'] == "attachment; filename*=UTF-8''test.docx" + assert form(responses.calls[1])['File'] == 'abc' + assert saved == [str(tmp_path / 'test.pdf')] + assert (tmp_path / 'test.pdf').read_bytes() == b'%PDF' + + +@responses.activate +def test_upload_io_and_download_io(): + responses.post(BASE + 'upload', json={ 'FileId': 'abc', 'FileExt': 'txt' }) + responses.post(BASE + 'convert/txt/to/pdf', json=conversion('test.pdf')) + responses.get('https://v2.convertapi.com/d/test.pdf', body=b'%PDF') + + upload_io = convertapi.UploadIO(io.BytesIO(b'test'), 'test.txt') + result = convertapi.convert('pdf', { 'File': upload_io }) + + assert result.file.io.getvalue() == b'%PDF' + assert responses.calls[0].request.body == b'test' + + +@responses.activate +def test_chained_conversion_passes_result_urls(): + responses.post(BASE + 'convert/any/to/zip', json=conversion('test.zip')) + previous = convertapi.result.Result(conversion('a.pdf', 'b.pdf')) + + convertapi.convert('zip', { 'Files': previous.files }) + + sent = form(responses.calls[0]) + assert sent['Files[0]'] == 'https://v2.convertapi.com/d/a.pdf' + assert sent['Files[1]'] == 'https://v2.convertapi.com/d/b.pdf' + + +@responses.activate +def test_api_error(): + responses.post(BASE + 'convert/web/to/pdf', status=401, json={ 'Code': 4011, 'Message': 'Unauthorized.' }) + + with pytest.raises(convertapi.ApiError) as error: + convertapi.convert('pdf', { 'Url': 'https://example.com' }) + + assert error.value.code == 4011 + assert str(error.value) == 'Unauthorized. Code: 4011.' + + +@responses.activate +def test_http_error_without_json_body(): + responses.post(BASE + 'convert/web/to/pdf', status=502, body='Bad Gateway') + + with pytest.raises(Exception) as error: + convertapi.convert('pdf', { 'Url': 'https://example.com' }) + + assert '502' in str(error.value) diff --git a/tests/test_convertapi.py b/tests/test_convertapi.py index bd47e9c..4d4519a 100644 --- a/tests/test_convertapi.py +++ b/tests/test_convertapi.py @@ -1,77 +1,65 @@ -import convertapi -import os import io import tempfile -import requests -import responses - -from . import utils -from nose.tools import * - -class TestConvertapi(utils.TestCase): - def setUp(self): - convertapi.api_credentials = os.environ['CONVERT_API_SECRET'] - convertapi.max_parallel_uploads = 10 - - def test_defaults(self): - eq_('https://v2.convertapi.com/', convertapi.base_uri) - - def test_configuration(self): - convertapi.api_credentials = 'TEST' - eq_('TEST', convertapi.api_credentials) - - def test_convert_file(self): - result = convertapi.convert('pdf', { 'File': 'examples/files/test.docx' }) - assert result.save_files(tempfile.gettempdir()) - assert result.conversion_cost > 0 - - def test_convert_file_no_parallelizm(self): - convertapi.max_parallel_uploads = 1 - result = convertapi.convert('pdf', { 'File': 'examples/files/test.docx' }) - assert result.save_files(tempfile.gettempdir()) - - def test_convert_file_url(self): - result = convertapi.convert('pdf', { 'File': 'https://cdn.convertapi.com/cara/testfiles/document.docx?test=1' }) - assert result.conversion_cost > 0 - - def test_convert_url(self): - result = convertapi.convert('pdf', { 'Url': 'http://convertapi.com' }) - assert result.conversion_cost > 0 - - def test_convert_url_with_timeout_and_format(self): - result = convertapi.convert('pdf', { 'Url': 'https://www.w3.org/TR/PNG/iso_8859-1.txt' }, 'web', 100) - assert result.conversion_cost > 0 - - def test_upload_io(self): - bytes_io = io.BytesIO(b'test') - upload_io = convertapi.UploadIO(bytes_io, 'test.txt') - result = convertapi.convert('pdf', { 'File': upload_io }) - assert result.conversion_cost > 0 - - def test_download_io(self): - result = convertapi.convert('pdf', { 'Url': 'https://www.w3.org/TR/PNG/iso_8859-1.txt' }) - assert len(result.file.io.getvalue()) > 0 - - def test_zip_files(self): - files = ['examples/files/test.docx', 'examples/files/test.docx'] - result = convertapi.convert('zip', { 'Files': files }) - assert result.conversion_cost > 0 - - def test_chained_conversion(self): - result = convertapi.convert('pdf', { 'File': 'examples/files/test.docx' }) - zip_result = convertapi.convert('zip', { 'Files': result.files }) - eq_('test.zip', zip_result.file.filename) - - def test_compare_files(self): - file = 'examples/files/test.docx' - result = convertapi.convert('compare', { 'File': file, 'CompareFile': file }) - assert result.conversion_cost > 0 - - @raises(convertapi.ApiError) - def test_api_error(self): - convertapi.api_credentials = 'TEST' + +import pytest + +import convertapi + +pytestmark = [pytest.mark.live, pytest.mark.usefixtures('live')] + + +def test_convert_file(): + result = convertapi.convert('pdf', { 'File': 'examples/files/test.docx' }) + assert result.save_files(tempfile.gettempdir()) + assert result.conversion_cost > 0 + +def test_convert_file_no_parallelizm(): + convertapi.max_parallel_uploads = 1 + result = convertapi.convert('pdf', { 'File': 'examples/files/test.docx' }) + assert result.save_files(tempfile.gettempdir()) + +def test_convert_file_url(): + result = convertapi.convert('pdf', { 'File': 'https://cdn.convertapi.com/cara/testfiles/document.docx?test=1' }) + assert result.conversion_cost > 0 + +def test_convert_url(): + result = convertapi.convert('pdf', { 'Url': 'http://convertapi.com' }) + assert result.conversion_cost > 0 + +def test_convert_url_with_timeout_and_format(): + result = convertapi.convert('pdf', { 'Url': 'https://www.w3.org/TR/PNG/iso_8859-1.txt' }, 'web', 100) + assert result.conversion_cost > 0 + +def test_upload_io(): + bytes_io = io.BytesIO(b'test') + upload_io = convertapi.UploadIO(bytes_io, 'test.txt') + result = convertapi.convert('pdf', { 'File': upload_io }) + assert result.conversion_cost > 0 + +def test_download_io(): + result = convertapi.convert('pdf', { 'Url': 'https://www.w3.org/TR/PNG/iso_8859-1.txt' }) + assert len(result.file.io.getvalue()) > 0 + +def test_zip_files(): + files = ['examples/files/test.docx', 'examples/files/test.docx'] + result = convertapi.convert('zip', { 'Files': files }) + assert result.conversion_cost > 0 + +def test_chained_conversion(): + result = convertapi.convert('pdf', { 'File': 'examples/files/test.docx' }) + zip_result = convertapi.convert('zip', { 'Files': result.files }) + assert zip_result.file.filename == 'test.zip' + +def test_compare_files(): + file = 'examples/files/test.docx' + result = convertapi.convert('compare', { 'File': file, 'CompareFile': file }) + assert result.conversion_cost > 0 + +def test_api_error(): + convertapi.api_credentials = 'TEST' + with pytest.raises(convertapi.ApiError): convertapi.convert('pdf', { 'Url': 'https://www.w3.org/TR/PNG/iso_8859-1.txt' }) - def test_user_info(self): - user_info = convertapi.user() - assert user_info['Active'] +def test_user_info(): + user_info = convertapi.user() + assert user_info['Active'] diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 9918cd5..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,4 +0,0 @@ -import unittest - -class TestCase(unittest.TestCase): - pass \ No newline at end of file