diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1d3e6d2..a969719 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.10', '3.11', '3.12', '3.13'] + python: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 484d821..0b6c9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,19 @@ 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 +## [2.0.1] — 2026-10-20 + +### Fixed +- Converting several local files at once (`Files`) failed with `TypeError: can only concatenate str (not + "NoneType") to str` on macOS and on Python 3.14+ on Linux: the parallel uploads ran in child processes, + which do not inherit `api_credentials` under the spawn and forkserver start methods. They now run in + threads, honouring `max_parallel_uploads` as before. ### 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 +- Tested on Python 3.10–3.14 (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). diff --git a/README.md b/README.md index 30ee95f..bed054e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Install from source with: ### Requirements -* Python 3.10+ (tested on 3.10–3.13) +* Python 3 with `requests`; tested on Python 3.10–3.14 ## Usage diff --git a/RELEASING.md b/RELEASING.md index ffbeb52..961a9d9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,7 +2,7 @@ `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. +tests on Python 3.10–3.14, 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`: diff --git a/convertapi/utils.py b/convertapi/utils.py index 226c2db..f9c2b79 100644 --- a/convertapi/utils.py +++ b/convertapi/utils.py @@ -1,12 +1,10 @@ -import multiprocessing +from concurrent.futures import ThreadPoolExecutor +# Uploads wait on the network, so threads are enough, and unlike processes they share the caller's +# configuration (api_credentials, base_uri, ...) whatever multiprocessing start method Python uses. def map_in_parallel(f, values, pool_size): if pool_size < 2: - return map(f, values) + return list(map(f, values)) - pool = multiprocessing.Pool(pool_size) - results = pool.map_async(f, values) - pool.close() - pool.join() - - return results.get() + with ThreadPoolExecutor(max_workers = pool_size) as pool: + return list(pool.map(f, values)) diff --git a/pyproject.toml b/pyproject.toml index 2d6758f..2ef6b46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", ] diff --git a/tests/test_client.py b/tests/test_client.py index f75a079..b4335f0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -130,3 +130,22 @@ def test_http_error_without_json_body(): convertapi.convert('pdf', { 'Url': 'https://example.com' }) assert '502' in str(error.value) + + +@pytest.mark.parametrize('max_parallel_uploads', [1, 10]) +@responses.activate +def test_convert_files_uploads_each(max_parallel_uploads): + # Each upload must carry the caller's configuration: in 2.0.0 parallel uploads ran in child processes, + # which lost api_credentials under the spawn and forkserver start methods (macOS, Python 3.14 on Linux). + convertapi.max_parallel_uploads = max_parallel_uploads + responses.post(BASE + 'upload', json={ 'FileId': 'abc', 'FileExt': 'docx' }) + responses.post(BASE + 'convert/any/to/zip', json=conversion('test.zip')) + + convertapi.convert('zip', { 'Files': ['examples/files/test.docx', 'examples/files/test.pdf'] }) + + uploads = [call.request for call in responses.calls if call.request.url == BASE + 'upload'] + assert [r.headers['Authorization'] for r in uploads] == ['Bearer test-token'] * 2 + assert sorted(r.headers['Content-Disposition'] for r in uploads) == [ + "attachment; filename*=UTF-8''test.docx", "attachment; filename*=UTF-8''test.pdf"] + sent = form(responses.calls[-1]) + assert sent['Files[0]'] == 'abc' and sent['Files[1]'] == 'abc'