From 6fb039d483676a9d45cdf7049debcba0ef782d2a Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 10:49:35 +0300 Subject: [PATCH 1/3] Upload Files in threads, not processes; test on Python 3.14 Parallel uploads ran in a multiprocessing.Pool. Under the spawn (macOS) and forkserver (Linux, Python 3.14+) start methods the child processes re-import convertapi and lose api_credentials, so convert(..., {'Files': [local paths]}) failed with a TypeError. A ThreadPoolExecutor keeps the caller's configuration and suits network-bound uploads. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 2 +- CHANGELOG.md | 8 +++++++- README.md | 2 +- RELEASING.md | 2 +- convertapi/utils.py | 14 ++++++-------- pyproject.toml | 1 + tests/test_client.py | 19 +++++++++++++++++++ 7 files changed, 36 insertions(+), 12 deletions(-) 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..5e9c150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,17 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver ## [2.0.1] — Unreleased +### 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..6db3fca 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.10+ (tested on 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' From aa60c27c32790d62dec11e78244747a265bd0ad6 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 10:53:40 +0300 Subject: [PATCH 2/3] README: state the tested Python versions, not a minimum the metadata does not enforce Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6db3fca..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.14) +* Python 3 with `requests`; tested on Python 3.10–3.14 ## Usage From b22046f350481f8898df20d702ff531a9f35c2ba Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Tue, 20 Oct 2026 10:55:24 +0300 Subject: [PATCH 3/3] CHANGELOG: date 2.0.1 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9c150..0b6c9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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