Skip to content

Commit d653b60

Browse files
authored
Merge pull request #58 from ConvertAPI/fix-parallel-uploads
Upload Files in threads, not processes; test on Python 3.14
2 parents 9823ef1 + b22046f commit d653b60

7 files changed

Lines changed: 37 additions & 13 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
strategy:
3232
fail-fast: false
3333
matrix:
34-
python: ['3.10', '3.11', '3.12', '3.13']
34+
python: ['3.10', '3.11', '3.12', '3.13', '3.14']
3535
steps:
3636
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
3737
with:

‎CHANGELOG.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
66

7-
## [2.0.1] — Unreleased
7+
## [2.0.1] — 2026-10-20
8+
9+
### Fixed
10+
- Converting several local files at once (`Files`) failed with `TypeError: can only concatenate str (not
11+
"NoneType") to str` on macOS and on Python 3.14+ on Linux: the parallel uploads ran in child processes,
12+
which do not inherit `api_credentials` under the spawn and forkserver start methods. They now run in
13+
threads, honouring `max_parallel_uploads` as before.
814

915
### Changed
1016
- Packaging moved from `setup.py` to `pyproject.toml`. The PyPI page now shows this README and links to
1117
the source, changelog and issues; the wheel is `py3-none-any` (it was tagged `py2.py3`, although
1218
Python 2 has not been supported since 1.7.0). No change to the library's API or dependencies.
13-
- Tested on Python 3.10–3.13 (was 3.7 and 3.9), with pytest instead of nose. The tests run without network
19+
- Tested on Python 3.10–3.14 (was 3.7 and 3.9), with pytest instead of nose. The tests run without network
1420
access; the ones that call the real service are marked `live`.
1521
- Releases are now cut from a GitHub Release `v{version}`; the package version comes from the tag and is
1622
uploaded with PyPI Trusted Publishing. See [RELEASING.md](RELEASING.md).

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Install from source with:
2020

2121
### Requirements
2222

23-
* Python 3.10+ (tested on 3.10–3.13)
23+
* Python 3 with `requests`; tested on Python 3.10–3.14
2424

2525
## Usage
2626

‎RELEASING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
`main` is the only long-lived branch. Changes reach it through pull requests, merge commits only, and
44
every pull request and push to `main` must pass the `build` check (`.github/workflows/build.yml`: the
5-
tests on Python 3.10–3.13, then build and check the sdist and wheel). Merging publishes nothing.
5+
tests on Python 3.10–3.14, then build and check the sdist and wheel). Merging publishes nothing.
66

77
A release is a **GitHub Release** whose tag is the version, published by `tomasr78`:
88

‎convertapi/utils.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import multiprocessing
1+
from concurrent.futures import ThreadPoolExecutor
22

3+
# Uploads wait on the network, so threads are enough, and unlike processes they share the caller's
4+
# configuration (api_credentials, base_uri, ...) whatever multiprocessing start method Python uses.
35
def map_in_parallel(f, values, pool_size):
46
if pool_size < 2:
5-
return map(f, values)
7+
return list(map(f, values))
68

7-
pool = multiprocessing.Pool(pool_size)
8-
results = pool.map_async(f, values)
9-
pool.close()
10-
pool.join()
11-
12-
return results.get()
9+
with ThreadPoolExecutor(max_workers = pool_size) as pool:
10+
return list(pool.map(f, values))

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ classifiers = [
2222
"Programming Language :: Python :: 3.11",
2323
"Programming Language :: Python :: 3.12",
2424
"Programming Language :: Python :: 3.13",
25+
"Programming Language :: Python :: 3.14",
2526
"Topic :: Software Development :: Libraries :: Python Modules",
2627
]
2728

‎tests/test_client.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,22 @@ def test_http_error_without_json_body():
130130
convertapi.convert('pdf', { 'Url': 'https://example.com' })
131131

132132
assert '502' in str(error.value)
133+
134+
135+
@pytest.mark.parametrize('max_parallel_uploads', [1, 10])
136+
@responses.activate
137+
def test_convert_files_uploads_each(max_parallel_uploads):
138+
# Each upload must carry the caller's configuration: in 2.0.0 parallel uploads ran in child processes,
139+
# which lost api_credentials under the spawn and forkserver start methods (macOS, Python 3.14 on Linux).
140+
convertapi.max_parallel_uploads = max_parallel_uploads
141+
responses.post(BASE + 'upload', json={ 'FileId': 'abc', 'FileExt': 'docx' })
142+
responses.post(BASE + 'convert/any/to/zip', json=conversion('test.zip'))
143+
144+
convertapi.convert('zip', { 'Files': ['examples/files/test.docx', 'examples/files/test.pdf'] })
145+
146+
uploads = [call.request for call in responses.calls if call.request.url == BASE + 'upload']
147+
assert [r.headers['Authorization'] for r in uploads] == ['Bearer test-token'] * 2
148+
assert sorted(r.headers['Content-Disposition'] for r in uploads) == [
149+
"attachment; filename*=UTF-8''test.docx", "attachment; filename*=UTF-8''test.pdf"]
150+
sent = form(responses.calls[-1])
151+
assert sent['Files[0]'] == 'abc' and sent['Files[1]'] == 'abc'

0 commit comments

Comments
 (0)