comparison scripts/test.py @ 41:71bcc18e306f

MERGE: New upstream PyMuPDF v1.26.5 including MuPDF v1.26.10 BUGS: Needs some additional changes yet. Not yet tested.
author Franz Glasner <fzglas.hg@dom66.de>
date Sat, 11 Oct 2025 15:24:40 +0200
parents a6bc019ac0b2
children
comparison
equal deleted inserted replaced
38:8934ac156ef5 41:71bcc18e306f
2 2
3 '''Developer build/test script for PyMuPDF. 3 '''Developer build/test script for PyMuPDF.
4 4
5 Examples: 5 Examples:
6 6
7 ./PyMuPDF/scripts/test.py --m mupdf build test 7 ./PyMuPDF/scripts/test.py -m mupdf build test
8 Build and test with pre-existing local mupdf/ checkout. 8 Build and test with pre-existing local mupdf/ checkout.
9 9
10 ./PyMuPDF/scripts/test.py build test 10 ./PyMuPDF/scripts/test.py build test
11 Build and test with default internal download of mupdf. 11 Build and test with default internal download of mupdf.
12 12
13 ./PyMuPDF/scripts/test.py -m 'git:https://git.ghostscript.com/mupdf.git' build test 13 ./PyMuPDF/scripts/test.py -m 'git:https://git.ghostscript.com/mupdf.git' build test
14 Build and test with internal checkout of MuPDF master. 14 Build and test with internal checkout of MuPDF master.
15 15
16 ./PyMuPDF/scripts/test.py -m 'git:--branch 1.26.x https://github.com/ArtifexSoftware/mupdf.git' build test 16 ./PyMuPDF/scripts/test.py -m ':1.26.x' build test
17 Build and test using internal checkout of mupdf 1.26.x branch from 17 Build and test using internal checkout of mupdf 1.26.x branch from
18 Github. 18 Github.
19
20 ./PyMuPDF/scripts/test.py install test -i 1.26.3 -k test_2596
21 Install pymupdf-1.26.3 from pupi.org and test only test_2596.
19 22
20 Usage: 23 Usage:
21 24
22 * Command line arguments are called parameters if they start with `-`, 25 * Command line arguments are called parameters if they start with `-`,
23 otherwise they are called commands. 26 otherwise they are called commands.
29 them on the command line. 32 them on the command line.
30 33
31 Other: 34 Other:
32 35
33 * If we are not already running inside a Python venv, we automatically create a 36 * If we are not already running inside a Python venv, we automatically create a
34 venv and re-run ourselves inside it. 37 venv and re-run ourselves inside it (also see the -v option).
35 * Build/wheel/install commands always install into the venv. 38 * Build/wheel/install commands always install into the venv.
36 * Tests use whatever PyMuPDF/MuPDF is currently installed in the venv. 39 * Tests use whatever PyMuPDF/MuPDF is currently installed in the venv.
37 * We run tests with pytest. 40 * We run tests with pytest.
38 41
39 * One can generate call traces by setting environment variables in debug 42 * One can generate call traces by setting environment variables in debug
53 'release', 'debug', 'memento'. [This makes `build` set environment 56 'release', 'debug', 'memento'. [This makes `build` set environment
54 variable `PYMUPDF_SETUP_MUPDF_BUILD_TYPE`, which is used by PyMuPDF's 57 variable `PYMUPDF_SETUP_MUPDF_BUILD_TYPE`, which is used by PyMuPDF's
55 `setup.py`.] 58 `setup.py`.]
56 59
57 --build-flavour <build_flavour> 60 --build-flavour <build_flavour>
61 [Obsolete.]
58 Combination of 'p', 'b', 'd'. See ../setup.py's description of 62 Combination of 'p', 'b', 'd'. See ../setup.py's description of
59 PYMUPDF_SETUP_FLAVOUR. Default is 'pbd', i.e. self-contained PyMuPDF 63 PYMUPDF_SETUP_FLAVOUR. Default is 'pbd', i.e. self-contained PyMuPDF
60 wheels including MuPDF build-time files. 64 wheels including MuPDF build-time files.
61 65
62 --build-isolation 0|1 66 --build-isolation 0|1
69 this allows control over whether to build linux-aarch64 wheels. 73 this allows control over whether to build linux-aarch64 wheels.
70 74
71 --cibw-name <cibw_name> 75 --cibw-name <cibw_name>
72 Name to use when installing cibuildwheel, e.g.: 76 Name to use when installing cibuildwheel, e.g.:
73 --cibw-name cibuildwheel==3.0.0b1 77 --cibw-name cibuildwheel==3.0.0b1
78 --cibw-name git+https://github.com/pypa/cibuildwheel
74 Default is `cibuildwheel`, i.e. the current release. 79 Default is `cibuildwheel`, i.e. the current release.
75 80
76 --cibw-pyodide 0|1 81 --cibw-pyodide 0|1
77 Experimental, make `cibuild` command build a pyodide wheel. 82 Experimental, make `cibw` command build a pyodide wheel.
78 2025-05-27: this fails when building mupdf C API - `ld -r -b binary 83 2025-05-27: this fails when building mupdf C API - `ld -r -b binary
79 ...` fails with: 84 ...` fails with:
80 emcc: error: binary: No such file or directory ("binary" was expected to be an input file, based on the commandline arguments provided) 85 emcc: error: binary: No such file or directory ("binary" was expected to be an input file, based on the commandline arguments provided)
81 86
82 --cibw-pyodide-version <cibw_pyodide_version> 87 --cibw-pyodide-version <cibw_pyodide_version>
88 if on Linux. 93 if on Linux.
89 94
90 --cibw-release-2 95 --cibw-release-2
91 Set up so that `cibw` builds only linux-aarch64 wheel. 96 Set up so that `cibw` builds only linux-aarch64 wheel.
92 97
98 --cibw-skip-add-defaults 0|1
99 If 1 (the default) we add defaults to CIBW_SKIP such as `pp*` (to
100 exclude pypy) and `cp3??t-*` (to exclude free-threading).
101
102 --cibw-test-project 0|1
103 If 1, command `cibw` will use a minimal test project instead of the
104 PyMuPDF directory itself.
105
106 The test project uses setjmp/longjmp and C++ throw/catch.
107
108 The test checks for current behaviour, so with `--cibw-pyodide 1` it
109 succeeds if the cibw command fails with the expected error message.
110
111 2025-08-22:
112 Builds ok on Linux.
113
114 Fails at runtime with --cibw-pyodide 1:
115
116 With compile/link flags ``:
117 (+45.0s): remote.py:233:main: jules-devuan: Pyodide has suffered a fatal error. Please report this to the Pyodide maintainers.
118 (+45.1s): remote.py:233:main: jules-devuan: Stack (most recent call first):
119 (+45.1s): remote.py:233:main: jules-devuan: File "/tmp/cibw-run-h_pfo0wf/cp312-pyodide_wasm32/venv-test/lib/python3.12/site-packages/foo/__init__.py", line 63 in bar
120 (+45.1s): remote.py:233:main: jules-devuan: File "<string>The cause of the fatal error was:
121 (+45.1s): remote.py:233:main: jules-devuan: CppException std::runtime_error: deliberate exception
122 (+45.1s): remote.py:233:main: jules-devuan: at convertCppException (/home/jules/.cache/cibuildwheel/pyodide-build-0.30.7/0.27.7/xbuildenv/pyodide-root/dist/pyodide.asm.js:10:48959)
123 (+45.1s): remote.py:233:main: jules-devuan: at API.fatal_error (/home/jules/.cache/cibuildwheel/pyodide-build-0.30.7/0.27.7/xbuildenv/pyodide-root/dist/pyodide.asm.js:10:49253)
124 (+45.1s): remote.py:233:main: jules-devuan: at main (file:///home/jules/.cache/cibuildwheel/pyodide-build-0.30.7/0.27.7/xbuildenv/pyodide-root/dist/python_cli_entry.mjs:149:13) {
125 (+45.1s): remote.py:233:main: jules-devuan: ty: 'std::runtime_error',
126 (+45.1s): remote.py:233:main: jules-devuan: pyodide_fatal_error: true
127 (+45.1s): remote.py:233:main: jules-devuan: }
128 (+45.1s): remote.py:233:main: jules-devuan: ", line 1 in <module>
129 (+45.1s): remote.py:233:main: jules-devuan: CppException std::runtime_error: deliberate exception
130 (+45.1s): remote.py:233:main: jules-devuan: at convertCppException (/home/jules/.cache/cibuildwheel/pyodide-build-0.30.7/0.27.7/xbuildenv/pyodide-root/dist/pyodide.asm.js:10:48959)
131 (+45.1s): remote.py:233:main: jules-devuan: at API.fatal_error (/home/jules/.cache/cibuildwheel/pyodide-build-0.30.7/0.27.7/xbuildenv/pyodide-root/dist/pyodide.asm.js:10:49253)
132 (+45.1s): remote.py:233:main: jules-devuan: at main (file:///home/jules/.cache/cibuildwheel/pyodide-build-0.30.7/0.27.7/xbuildenv/pyodide-root/dist/python_cli_entry.mjs:149:13) {
133 (+45.1s): remote.py:233:main: jules-devuan: ty: 'std::runtime_error',
134 (+45.1s): remote.py:233:main: jules-devuan: pyodide_fatal_error: true
135 (+45.1s): remote.py:233:main: jules-devuan: }
136
137 With compile/link flags `-fwasm-exceptions`:
138 [LinkError: WebAssembly.instantiate(): Import #60 module="env" function="__c_longjmp": tag import requires a WebAssembly.Tag]
139
140 With compile/link flags `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm`:
141 [LinkError: WebAssembly.instantiate(): Import #60 module="env" function="__c_longjmp": tag import requires a WebAssembly.Tag]
142
143 --cibw-test-project-setjmp 0|1
144 If 1, --cibw-test-project builds a project that uses
145 setjmp/longjmp. Default is 0 (Windows builds fail when attempting to
146 compile the output from swig).
147
93 -d 148 -d
94 Equivalent to `-b debug`. 149 Equivalent to `-b debug`.
95 150
96 --dummy 151 --dummy
97 Sets PYMUPDF_SETUP_DUMMY=1 which makes setup.py build a dummy wheel 152 Sets PYMUPDF_SETUP_DUMMY=1 which makes setup.py build a dummy wheel
101 Add to environment used in build and test commands. Can be specified 156 Add to environment used in build and test commands. Can be specified
102 multiple times. 157 multiple times.
103 158
104 -f 0|1 159 -f 0|1
105 If 1 we also test alias `fitz` as well as `pymupdf`. Default is '0'. 160 If 1 we also test alias `fitz` as well as `pymupdf`. Default is '0'.
106
107 --gdb 0|1
108 Run tests under gdb. Requires user interaction.
109 161
110 --graal 162 --graal
111 Use graal - run inside a Graal VM instead of a Python venv. 163 Use graal - run inside a Graal VM instead of a Python venv.
112 164
113 As of 2025-08-04 we: 165 As of 2025-08-04 we:
128 'r' - rebased. 180 'r' - rebased.
129 'R' - rebased without optimisations. 181 'R' - rebased without optimisations.
130 Default is 'r'. Also see `PyMuPDF:tests/run_compound.py`. 182 Default is 'r'. Also see `PyMuPDF:tests/run_compound.py`.
131 183
132 -i <install_version> 184 -i <install_version>
133 Set version installed by the 'install' command. 185 Controls behaviour of `install` command:
186
187 * If <install_version> ends with `.whl` we use `pip install
188 <install_version>`.
189 * If <install_version> starts with == or >= or >, we use `pip install
190 pymupdf<install_version>`.
191 * Otherwise we use `pip install pymupdf==<install_version>`.
134 192
135 -k <expression> 193 -k <expression>
136 Specify which test(s) to run; passed straight through to pytest's `-k`. 194 Specify which test(s) to run; passed straight through to pytest's `-k`.
137 For example `-k test_3354`. 195 For example `-k test_3354`.
138 196
139 -m <location> | --mupdf <location> 197 -m <location> | --mupdf <location>
140 Location of local mupdf/ directory or 'git:...' to be used 198 Location of mupdf as local directory or remote git, to be used when
141 when building PyMuPDF. 199 building PyMuPDF.
142 200
143 This sets environment variable PYMUPDF_SETUP_MUPDF_BUILD, which is used 201 This sets environment variable PYMUPDF_SETUP_MUPDF_BUILD, which is used
144 by PyMuPDF/setup.py. If not specified PyMuPDF will download its default 202 by PyMuPDF/setup.py. If not specified PyMuPDF will download its default
145 mupdf .tgz. 203 mupdf .tgz.
146 204
174 -p <pytest-options> 232 -p <pytest-options>
175 Set pytest options; default is ''. 233 Set pytest options; default is ''.
176 234
177 -P 0|1 235 -P 0|1
178 If 1, automatically install required system packages such as 236 If 1, automatically install required system packages such as
179 Valgrind. Default is 0. 237 Valgrind. Default is 1 if running as Github action, otherwise 0.
180 238
181 --pybind 0|1 239 --pybind 0|1
182 Experimental, for investigating 240 Experimental, for investigating
183 https://github.com/pymupdf/PyMuPDF/issues/3869. Runs run basic code 241 https://github.com/pymupdf/PyMuPDF/issues/3869. Runs run basic code
184 inside C++ pybind. Requires `sudo apt install pybind11-dev` or similar. 242 inside C++ pybind. Requires `sudo apt install pybind11-dev` or similar.
195 PyMuPDF/setup.py.] 253 PyMuPDF/setup.py.]
196 254
197 --show-args: 255 --show-args:
198 Show sys.argv and exit. For debugging. 256 Show sys.argv and exit. For debugging.
199 257
200 --sync-paths 258 --sync-paths <path>
201 Do not run anything, instead write required files/directories/checkouts 259 Do not run anything, instead write required files/directories/checkouts
202 to stdout, one per line. This is to help with automated running on 260 to <path>, one per line. This is to help with automated running on
203 remote machines. 261 remote machines.
204 262
205 --system-site-packages 0|1 263 --system-site-packages 0|1
206 If 1, use `--system-site-packages` when creating venv. Defaults is 0. 264 If 1, use `--system-site-packages` when creating venv. Defaults is 0.
207 265
239 297
240 -T <prefix> 298 -T <prefix>
241 Use specified prefix when running pytest, must be one of: 299 Use specified prefix when running pytest, must be one of:
242 gdb 300 gdb
243 helgrind 301 helgrind
244 vagrind 302 valgrind
245 303
246 -v <venv> 304 -v <venv>
247 venv is: 305 venv is:
248 0 - do not use a venv. 306 0 - do not use a venv.
249 1 - Use venv. If it already exists, we assume the existing directory 307 1 - Use venv. If it already exists, we assume the existing directory
330 388
331 log = pipcl.log0 389 log = pipcl.log0
332 run = pipcl.run 390 run = pipcl.run
333 391
334 392
393 # We build and test Python 3.x for x in this range.
394 python_versions_minor = range(9, 14+1)
395
396 def cibw_cp(*version_minors):
397 '''
398 Returns <version_tuples> in 'cp39*' format, e.g. suitable for CIBW_BUILD.
399 '''
400 ret = list()
401 for version_minor in version_minors:
402 ret.append(f'cp3{version_minor}*')
403 return ' '.join(ret)
404
405
335 def main(argv): 406 def main(argv):
336 407
337 if github_workflow_unimportant(): 408 if github_workflow_unimportant():
338 return 409 return
339 410
340 build_isolation = None 411 build_isolation = None
341 cibw_name = None 412 cibw_name = None
342 cibw_pyodide = None 413 cibw_pyodide = None
343 cibw_pyodide_version = None 414 cibw_pyodide_version = None
415 cibw_skip_add_defaults = True
416 cibw_test_project = None
417 cibw_test_project_setjmp = False
344 commands = list() 418 commands = list()
345 env_extra = dict() 419 env_extra = dict()
346 graal = False 420 graal = False
347 implementations = 'r' 421 implementations = 'r'
348 install_version = None 422 install_version = None
349 mupdf_sync = None 423 mupdf_sync = None
350 os_names = list() 424 os_names = list()
351 system_packages = False 425 system_packages = True if os.environ.get('GITHUB_ACTIONS') == 'true' else False
352 pybind = False 426 pybind = False
353 pyodide_build_version = None 427 pyodide_build_version = None
354 pytest_options = '' 428 pytest_options = ''
355 pytest_prefix = None 429 pytest_prefix = None
356 cibw_sdist = None 430 cibw_sdist = None
406 elif arg == '--cibw-release-1': 480 elif arg == '--cibw-release-1':
407 cibw_sdist = True 481 cibw_sdist = True
408 env_extra['CIBW_ARCHS_LINUX'] = 'auto64' 482 env_extra['CIBW_ARCHS_LINUX'] = 'auto64'
409 env_extra['CIBW_ARCHS_MACOS'] = 'auto64' 483 env_extra['CIBW_ARCHS_MACOS'] = 'auto64'
410 env_extra['CIBW_ARCHS_WINDOWS'] = 'auto' # win32 and win64. 484 env_extra['CIBW_ARCHS_WINDOWS'] = 'auto' # win32 and win64.
411 env_extra['CIBW_SKIP'] = 'pp* *i686 cp36* cp37* *musllinux*aarch64*' 485 env_extra['CIBW_SKIP'] = '*i686 *musllinux*aarch64* cp3??t-*'
486 cibw_skip_add_defaults = 0
412 487
413 elif arg == '--cibw-release-2': 488 elif arg == '--cibw-release-2':
414 env_extra['CIBW_ARCHS_LINUX'] = 'aarch64'
415 # Testing only first and last python versions because otherwise 489 # Testing only first and last python versions because otherwise
416 # Github times out after 6h. 490 # Github times out after 6h.
417 env_extra['CIBW_BUILD'] = 'cp39* cp313*' 491 env_extra['CIBW_BUILD'] = cibw_cp(python_versions_minor[0], python_versions_minor[-1])
492 env_extra['CIBW_ARCHS_LINUX'] = 'aarch64'
493 env_extra['CIBW_SKIP'] = '*i686 *musllinux*aarch64* cp3??t-*'
494 cibw_skip_add_defaults = 0
418 os_names = ['linux'] 495 os_names = ['linux']
419 496
420 elif arg == '--cibw-archs-linux': 497 elif arg == '--cibw-archs-linux':
421 env_extra['CIBW_ARCHS_LINUX'] = next(args) 498 env_extra['CIBW_ARCHS_LINUX'] = next(args)
422 499
423 elif arg == '--cibw-name': 500 elif arg == '--cibw-name':
424 cibw_name = next(args) 501 cibw_name = next(args)
425 502
426 elif arg == '--cibw-pyodide': 503 elif arg == '--cibw-pyodide':
427 cibw_pyodide = next(args) 504 cibw_pyodide = int(next(args))
505
506 elif arg == '--cibw-skip-add-defaults':
507 cibw_skip_add_defaults = int(next(args))
508
509 elif arg == '--cibw-test-project':
510 cibw_test_project = int(next(args))
511
512 elif arg == '--cibw-test-project-setjmp':
513 cibw_test_project_setjmp = int(next(args))
428 514
429 elif arg == '-d': 515 elif arg == '-d':
430 env_extra['PYMUPDF_SETUP_MUPDF_BUILD_TYPE'] = 'debug' 516 env_extra['PYMUPDF_SETUP_MUPDF_BUILD_TYPE'] = 'debug'
431 517
432 elif arg == '--dummy': 518 elif arg == '--dummy':
461 _mupdf = next(args) 547 _mupdf = next(args)
462 if _mupdf == '-': 548 if _mupdf == '-':
463 _mupdf = None 549 _mupdf = None
464 elif _mupdf.startswith(':'): 550 elif _mupdf.startswith(':'):
465 _branch = _mupdf[1:] 551 _branch = _mupdf[1:]
466 _mupdf = 'git:--branch {_branch} https://github.com/ArtifexSoftware/mupdf.git' 552 _mupdf = f'git:--branch {_branch} https://github.com/ArtifexSoftware/mupdf.git'
467 os.environ['PYMUPDF_SETUP_MUPDF_BUILD'] = _mupdf 553 env_extra['PYMUPDF_SETUP_MUPDF_BUILD'] = _mupdf
468 elif _mupdf.startswith('git:') or '://' in _mupdf: 554 elif _mupdf.startswith('git:') or '://' in _mupdf:
469 os.environ['PYMUPDF_SETUP_MUPDF_BUILD'] = _mupdf 555 env_extra['PYMUPDF_SETUP_MUPDF_BUILD'] = _mupdf
470 else: 556 else:
471 assert os.path.isdir(_mupdf), f'Not a directory: {_mupdf=}' 557 assert os.path.isdir(_mupdf), f'Not a directory: {_mupdf=}'
472 os.environ['PYMUPDF_SETUP_MUPDF_BUILD'] = os.path.abspath(_mupdf) 558 env_extra['PYMUPDF_SETUP_MUPDF_BUILD'] = os.path.abspath(_mupdf)
473 mupdf_sync = _mupdf 559 mupdf_sync = _mupdf
474 560
475 elif arg == '--mupdf-clean': 561 elif arg == '--mupdf-clean':
476 env_extra['PYMUPDF_SETUP_MUPDF_CLEAN']=next(args) 562 env_extra['PYMUPDF_SETUP_MUPDF_CLEAN']=next(args)
477 563
499 env_extra['PYMUPDF_SETUP_PY_LIMITED_API'] = _value 585 env_extra['PYMUPDF_SETUP_PY_LIMITED_API'] = _value
500 586
501 elif arg == '--show-args': 587 elif arg == '--show-args':
502 show_args = 1 588 show_args = 1
503 elif arg == '--sync-paths': 589 elif arg == '--sync-paths':
504 sync_paths = True 590 sync_paths = next(args)
505 591
506 elif arg == '--system-site-packages': 592 elif arg == '--system-site-packages':
507 system_site_packages = int(next(args)) 593 system_site_packages = int(next(args))
508 594
509 elif arg == '--swig': 595 elif arg == '--swig':
537 assert 0, f'Unrecognised option/command: {arg=}.' 623 assert 0, f'Unrecognised option/command: {arg=}.'
538 624
539 # Handle special args --sync-paths, -h, -v, -o first. 625 # Handle special args --sync-paths, -h, -v, -o first.
540 # 626 #
541 if sync_paths: 627 if sync_paths:
542 # Just print required files, directories and checkouts. 628 # Print required files, directories and checkouts.
543 print(pymupdf_dir) 629 with open(sync_paths, 'w') as f:
544 if mupdf_sync: 630 print(pymupdf_dir, file=f)
545 print(mupdf_sync) 631 if mupdf_sync:
632 print(mupdf_sync, file=f)
546 return 633 return
547 634
548 if show_help: 635 if show_help:
549 print(__doc__) 636 print(__doc__)
550 return 637 return
576 if venv >= 3: 663 if venv >= 3:
577 shutil.rmtree(venv_name, ignore_errors=1) 664 shutil.rmtree(venv_name, ignore_errors=1)
578 if venv == 1 and os.path.exists(pyenv_dir) and os.path.exists(venv_name): 665 if venv == 1 and os.path.exists(pyenv_dir) and os.path.exists(venv_name):
579 log(f'{venv=} and {venv_name=} already exists so not building pyenv or creating venv.') 666 log(f'{venv=} and {venv_name=} already exists so not building pyenv or creating venv.')
580 else: 667 else:
581 pipcl.git_get('https://github.com/pyenv/pyenv.git', pyenv_dir, branch='master') 668 pipcl.git_get(pyenv_dir, remote='https://github.com/pyenv/pyenv.git', branch='master')
582 run(f'cd {pyenv_dir} && src/configure && make -C src') 669 run(f'cd {pyenv_dir} && src/configure && make -C src')
583 run(f'which pyenv') 670 run(f'which pyenv')
584 run(f'pyenv install -v -s {graalpy}') 671 run(f'pyenv install -v -s {graalpy}')
585 run(f'{pyenv_dir}/versions/{graalpy}/bin/graalpy -m venv {venv_name}') 672 run(f'{pyenv_dir}/versions/{graalpy}/bin/graalpy -m venv {venv_name}')
586 e = run(f'. {venv_name}/bin/activate && python {shlex.join(sys.argv)}', 673 e = run(f'. {venv_name}/bin/activate && python {shlex.join(sys.argv)}',
620 ) 707 )
621 have_installed = True 708 have_installed = True
622 709
623 elif command == 'cibw': 710 elif command == 'cibw':
624 # Build wheel(s) with cibuildwheel. 711 # Build wheel(s) with cibuildwheel.
625 if cibw_pyodide and env_extra.get('CIBW_BUILD') is None: 712
626 assert 0, f'Need a Python version for Pyodide.' 713 if platform.system() == 'Linux':
627 CIBW_BUILD = 'cp312*' 714 PYMUPDF_SETUP_MUPDF_BUILD = env_extra.get('PYMUPDF_SETUP_MUPDF_BUILD')
628 env_extra['CIBW_BUILD'] = CIBW_BUILD 715 if PYMUPDF_SETUP_MUPDF_BUILD and not PYMUPDF_SETUP_MUPDF_BUILD.startswith('git:'):
629 log(f'Defaulting to {CIBW_BUILD=} for Pyodide.') 716 assert PYMUPDF_SETUP_MUPDF_BUILD.startswith('/')
630 #if cibw_pyodide_version == None: 717 env_extra['PYMUPDF_SETUP_MUPDF_BUILD'] = f'/host/{PYMUPDF_SETUP_MUPDF_BUILD}'
631 # cibw_pyodide_version = '0.28.0' 718
632 cibuildwheel( 719 cibuildwheel(
633 env_extra, 720 env_extra,
634 cibw_name or 'cibuildwheel', 721 cibw_name or 'cibuildwheel',
635 cibw_pyodide, 722 cibw_pyodide,
636 cibw_pyodide_version, 723 cibw_pyodide_version,
637 cibw_sdist, 724 cibw_sdist,
725 cibw_test_project,
726 cibw_test_project_setjmp,
727 cibw_skip_add_defaults,
638 ) 728 )
639 729
640 elif command == 'install': 730 elif command == 'install':
641 p = 'pymupdf' 731 p = 'pymupdf'
642 if install_version: 732 if install_version:
643 if not install_version.startswith(('==', '>=', '>')): 733 if install_version.endswith('.whl'):
644 p = f'{p}==' 734 p = install_version
645 p = f'{p}{install_version}' 735 elif install_version.startswith(('==', '>=', '>')):
736 p = f'{p}{install_version}'
737 else:
738 p = f'{p}=={install_version}'
646 run(f'pip install --force-reinstall {p}') 739 run(f'pip install --force-reinstall {p}')
647 have_installed = True 740 have_installed = True
648 741
649 elif command == 'test': 742 elif command == 'test':
650 if not have_installed: 743 if not have_installed:
737 *, 830 *,
738 build_isolation, 831 build_isolation,
739 venv, 832 venv,
740 wheel, 833 wheel,
741 ): 834 ):
742 print(f'{build_isolation=}') 835 log(f'{build_isolation=}')
743 836
744 if build_isolation is None: 837 if build_isolation is None:
745 # On OpenBSD libclang is not available on pypi.org, so we need to force 838 # On OpenBSD libclang is not available on pypi.org, so we need to force
746 # use of system package py3-llvm with --no-build-isolation, manually 839 # use of system package py3-llvm with --no-build-isolation, manually
747 # installing other required packages. 840 # installing other required packages.
773 run(f'pip install --force-reinstall {wheel}') 866 run(f'pip install --force-reinstall {wheel}')
774 else: 867 else:
775 run(f'pip install{build_isolation_text} -v --force-reinstall {pymupdf_dir_abs}', env_extra=env_extra) 868 run(f'pip install{build_isolation_text} -v --force-reinstall {pymupdf_dir_abs}', env_extra=env_extra)
776 869
777 870
778 def cibuildwheel(env_extra, cibw_name, cibw_pyodide, cibw_pyodide_version, cibw_sdist): 871 def cibuildwheel(
872 env_extra,
873 cibw_name,
874 cibw_pyodide,
875 cibw_pyodide_version,
876 cibw_sdist,
877 cibw_test_project,
878 cibw_test_project_setjmp,
879 cibw_skip_add_defaults,
880 ):
779 881
780 if cibw_sdist and platform.system() == 'Linux': 882 if cibw_sdist and platform.system() == 'Linux':
781 log(f'Building sdist.') 883 log(f'Building sdist.')
782 run(f'cd {pymupdf_dir_abs} && {sys.executable} setup.py -d wheelhouse sdist', env_extra=env_extra) 884 run(f'cd {pymupdf_dir_abs} && {sys.executable} setup.py -d wheelhouse sdist', env_extra=env_extra)
783 sdists = glob.glob(f'{pymupdf_dir_abs}/wheelhouse/pymupdf-*.tar.gz') 885 sdists = glob.glob(f'{pymupdf_dir_abs}/wheelhouse/pymupdf-*.tar.gz')
787 run(f'pip install --upgrade --force-reinstall {cibw_name}') 889 run(f'pip install --upgrade --force-reinstall {cibw_name}')
788 890
789 # Some general flags. 891 # Some general flags.
790 if 'CIBW_BUILD_VERBOSITY' not in env_extra: 892 if 'CIBW_BUILD_VERBOSITY' not in env_extra:
791 env_extra['CIBW_BUILD_VERBOSITY'] = '1' 893 env_extra['CIBW_BUILD_VERBOSITY'] = '1'
792 if 'CIBW_SKIP' not in env_extra: 894
793 env_extra['CIBW_SKIP'] = 'pp* *i686 cp36* cp37* *musllinux* *-win32 *-aarch64' 895 # Add default flags to CIBW_SKIP.
794 896 # 2025-10-07: `cp3??t-*` excludes free-threading, which currently breaks
897 # some tests.
898
899 if cibw_skip_add_defaults:
900 CIBW_SKIP = env_extra.get('CIBW_SKIP', '')
901 CIBW_SKIP += ' *i686 *musllinux* *-win32 *-aarch64 cp3??t-*'
902 CIBW_SKIP = CIBW_SKIP.split()
903 CIBW_SKIP = sorted(list(set(CIBW_SKIP)))
904 CIBW_SKIP = ' '.join(CIBW_SKIP)
905 env_extra['CIBW_SKIP'] = CIBW_SKIP
906
795 # Set what wheels to build, if not already specified. 907 # Set what wheels to build, if not already specified.
796 if 'CIBW_ARCHS' not in env_extra: 908 if 'CIBW_ARCHS' not in env_extra:
797 if 'CIBW_ARCHS_WINDOWS' not in env_extra: 909 if 'CIBW_ARCHS_WINDOWS' not in env_extra:
798 env_extra['CIBW_ARCHS_WINDOWS'] = 'auto64' 910 env_extra['CIBW_ARCHS_WINDOWS'] = 'auto64'
799 911
821 933
822 # Specify python versions. 934 # Specify python versions.
823 CIBW_BUILD = env_extra.get('CIBW_BUILD') 935 CIBW_BUILD = env_extra.get('CIBW_BUILD')
824 log(f'{CIBW_BUILD=}') 936 log(f'{CIBW_BUILD=}')
825 if CIBW_BUILD is None: 937 if CIBW_BUILD is None:
826 if os.environ.get('GITHUB_ACTIONS') == 'true': 938 if cibw_pyodide:
939 # Using python-3.13 fixes problems with MuPDF's setjmp/longjmp.
940 CIBW_BUILD = 'cp313*'
941 elif os.environ.get('GITHUB_ACTIONS') == 'true':
827 # Build/test all supported Python versions. 942 # Build/test all supported Python versions.
828 CIBW_BUILD = 'cp39* cp310* cp311* cp312* cp313*' 943 CIBW_BUILD = cibw_cp(*python_versions_minor)
829 else: 944 else:
830 # Build/test current Python only. 945 # Build/test current Python only.
831 v = platform.python_version_tuple()[:2] 946 v = platform.python_version_tuple()[:2]
832 log(f'{v=}') 947 log(f'{v=}')
833 CIBW_BUILD = f'cp{"".join(v)}*' 948 CIBW_BUILD = f'cp{"".join(v)}*'
949 log(f'Defaulting to {CIBW_BUILD=}.')
834 950
835 cibw_pyodide_args = '' 951 cibw_pyodide_args = ''
836 if cibw_pyodide: 952 if cibw_pyodide:
837 cibw_pyodide_args = ' --platform pyodide' 953 cibw_pyodide_args = ' --platform pyodide'
838 env_extra['HAVE_LIBCRYPTO'] = 'no' 954 env_extra['HAVE_LIBCRYPTO'] = 'no'
841 # 2025-07-21: there is no --pyodide-version option so we set 957 # 2025-07-21: there is no --pyodide-version option so we set
842 # CIBW_PYODIDE_VERSION. 958 # CIBW_PYODIDE_VERSION.
843 env_extra['CIBW_PYODIDE_VERSION'] = cibw_pyodide_version 959 env_extra['CIBW_PYODIDE_VERSION'] = cibw_pyodide_version
844 env_extra['CIBW_ENABLE'] = 'pyodide-prerelease' 960 env_extra['CIBW_ENABLE'] = 'pyodide-prerelease'
845 961
846 # Pass all the environment variables we have set, to Linux 962 # Pass all the environment variables we have set, to Linux docker. Note
847 # docker. Note that this will miss any settings in the original 963 # that this will miss any settings in the original environment. We have to
848 # environment. 964 # add CIBW_BUILD explicitly because we haven't set it yet.
849 env_extra['CIBW_ENVIRONMENT_PASS_LINUX'] = ' '.join(sorted(env_extra.keys())) 965 CIBW_ENVIRONMENT_PASS_LINUX = set(env_extra.keys())
850 966 CIBW_ENVIRONMENT_PASS_LINUX.add('CIBW_BUILD')
967 CIBW_ENVIRONMENT_PASS_LINUX = sorted(list(CIBW_ENVIRONMENT_PASS_LINUX))
968 CIBW_ENVIRONMENT_PASS_LINUX = ' '.join(CIBW_ENVIRONMENT_PASS_LINUX)
969 env_extra['CIBW_ENVIRONMENT_PASS_LINUX'] = CIBW_ENVIRONMENT_PASS_LINUX
970
971 if cibw_test_project:
972 cibw_do_test_project(
973 env_extra,
974 CIBW_BUILD,
975 cibw_pyodide,
976 cibw_pyodide_args,
977 cibw_test_project_setjmp,
978 )
979 return
980
851 # Build for lowest (assumed first) Python version. 981 # Build for lowest (assumed first) Python version.
852 # 982 #
853 CIBW_BUILD_0 = CIBW_BUILD.split()[0] 983 CIBW_BUILD_0 = CIBW_BUILD.split()[0]
854 log(f'Building for first Python version {CIBW_BUILD_0}.') 984 log(f'Building for first Python version {CIBW_BUILD_0}.')
855 env_extra['CIBW_BUILD'] = CIBW_BUILD_0 985 env_extra['CIBW_BUILD'] = CIBW_BUILD_0
857 987
858 # Tell cibuildwheel to build and test all specified Python versions; it 988 # Tell cibuildwheel to build and test all specified Python versions; it
859 # will notice that the wheel we built above supports all versions of 989 # will notice that the wheel we built above supports all versions of
860 # Python, so will not actually do any builds here. 990 # Python, so will not actually do any builds here.
861 # 991 #
992 # We only do this if there are more than one Python versions. This still
993 # duplicates the testing of the first python version.
994 if len(CIBW_BUILD.split()) > 1:
995 env_extra['CIBW_BUILD'] = CIBW_BUILD
996 run(f'cd {pymupdf_dir} && cibuildwheel{cibw_pyodide_args}', env_extra=env_extra)
997 run(f'ls -ld {pymupdf_dir}/wheelhouse/*')
998
999
1000 def cibw_do_test_project(
1001 env_extra,
1002 CIBW_BUILD,
1003 cibw_pyodide,
1004 cibw_pyodide_args,
1005 cibw_test_project_setjmp,
1006 ):
1007 testdir = f'{pymupdf_dir_abs}/cibw_test'
1008 shutil.rmtree(testdir, ignore_errors=1)
1009 os.mkdir(testdir)
1010 with open(f'{testdir}/setup.py', 'w') as f:
1011 f.write(textwrap.dedent(f'''
1012 import shutil
1013 import sys
1014 import os
1015 import pipcl
1016
1017 def build():
1018 so_leaf = pipcl.build_extension(
1019 name = 'foo',
1020 path_i = 'foo.i',
1021 outdir = 'build',
1022 source_extra = 'qwerty.cpp',
1023 py_limited_api = True,
1024 )
1025
1026 return [
1027 ('build/foo.py', 'foo/__init__.py'),
1028 (f'build/{{so_leaf}}', f'foo/'),
1029 ]
1030
1031 p = pipcl.Package(
1032 name = 'pymupdf-test',
1033 version = '1.2.3',
1034 fn_build = build,
1035 py_limited_api=True,
1036 )
1037
1038 def get_requires_for_build_wheel(config_settings=None):
1039 return ['swig']
1040
1041 build_wheel = p.build_wheel
1042 build_sdist = p.build_sdist
1043
1044 # Handle old-style setup.py command-line usage:
1045 if __name__ == '__main__':
1046 p.handle_argv(sys.argv)
1047 '''))
1048 with open(f'{testdir}/foo.i', 'w') as f:
1049 if cibw_test_project_setjmp:
1050 f.write(textwrap.dedent('''
1051 %{
1052 #include <stdexcept>
1053
1054 #include <assert.h>
1055 #include <setjmp.h>
1056 #include <stdio.h>
1057 #include <string.h>
1058
1059 int qwerty(void);
1060
1061 static sigjmp_buf jmpbuf;
1062 static int bar0(const char* text)
1063 {
1064 printf("bar0(): text: %s\\n", text);
1065
1066 int q = qwerty();
1067 printf("bar0(): q=%i\\n", q);
1068
1069 int len = (int) strlen(text);
1070 printf("bar0(): len=%i\\n", len);
1071 printf("bar0(): calling longjmp().\\n");
1072 fflush(stdout);
1073 longjmp(jmpbuf, 1);
1074 assert(0);
1075 }
1076 int bar1(const char* text)
1077 {
1078 int ret = 0;
1079 if (setjmp(jmpbuf) == 0)
1080 {
1081 ret = bar0(text);
1082 }
1083 else
1084 {
1085 printf("bar1(): setjmp() returned non-zero.\\n");
1086 throw std::runtime_error("deliberate exception");
1087 }
1088 assert(0);
1089 }
1090 int bar(const char* text)
1091 {
1092 int ret = 0;
1093 try
1094 {
1095 ret = bar1(text);
1096 }
1097 catch(std::exception& e)
1098 {
1099 printf("bar1(): received exception: %s\\n", e.what());
1100 }
1101 return ret;
1102 }
1103 %}
1104 int bar(const char* text);
1105 '''))
1106 else:
1107 f.write(textwrap.dedent('''
1108 %{
1109 #include <stdexcept>
1110
1111 #include <assert.h>
1112 #include <stdio.h>
1113 #include <string.h>
1114
1115 int qwerty(void);
1116
1117 int bar(const char* text)
1118 {
1119 qwerty();
1120 return strlen(text);
1121 }
1122 %}
1123 int bar(const char* text);
1124 '''))
1125
1126 with open(f'{testdir}/qwerty.cpp', 'w') as f:
1127 f.write(textwrap.dedent('''
1128 #include <stdio.h>
1129 int qwerty(void)
1130 {
1131 printf("qwerty()\\n");
1132 return 3;
1133 }
1134 '''))
1135
1136 with open(f'{testdir}/pyproject.toml', 'w') as f:
1137 f.write(textwrap.dedent('''
1138 [build-system]
1139 # We define required packages in setup.py:get_requires_for_build_wheel().
1140 requires = []
1141
1142 # See pep-517.
1143 #
1144 build-backend = "setup"
1145 backend-path = ["."]
1146 '''))
1147
1148 shutil.copy2(f'{pymupdf_dir_abs}/pipcl.py', f'{testdir}/pipcl.py')
1149 shutil.copy2(f'{pymupdf_dir_abs}/wdev.py', f'{testdir}/wdev.py')
1150
862 env_extra['CIBW_BUILD'] = CIBW_BUILD 1151 env_extra['CIBW_BUILD'] = CIBW_BUILD
863 run(f'cd {pymupdf_dir} && cibuildwheel{cibw_pyodide_args}', env_extra=env_extra) 1152 CIBW_TEST_COMMAND = ''
864 run(f'ls -ld {pymupdf_dir}/wheelhouse/*') 1153 if cibw_pyodide:
1154 CIBW_TEST_COMMAND += 'pyodide xbuildenv search --all; '
1155 CIBW_TEST_COMMAND += 'python -c "import foo; foo.bar(\\"some text\\")"'
1156 env_extra['CIBW_TEST_COMMAND'] = CIBW_TEST_COMMAND
1157 #env_extra['CIBW_TEST_COMMAND'] = ''
1158
1159 run(f'cd {testdir} && cibuildwheel --output-dir ../wheelhouse{cibw_pyodide_args}', env_extra=env_extra)
1160 run(f'ls -ldt {pymupdf_dir_abs}/wheelhouse/*')
865 1161
866 1162
867 def build_pyodide_wheel(pyodide_build_version=None): 1163 def build_pyodide_wheel(pyodide_build_version=None):
868 ''' 1164 '''
869 Build Pyodide wheel. 1165 Build Pyodide wheel.
1086 python = gh_release.relpath(sys.executable) 1382 python = gh_release.relpath(sys.executable)
1087 log('Running tests with tests/run_compound.py and pytest.') 1383 log('Running tests with tests/run_compound.py and pytest.')
1088 1384
1089 PYODIDE_ROOT = os.environ.get('PYODIDE_ROOT') 1385 PYODIDE_ROOT = os.environ.get('PYODIDE_ROOT')
1090 if PYODIDE_ROOT is not None: 1386 if PYODIDE_ROOT is not None:
1387 # We can't install packages with `pip install`; setup.py will have
1388 # specified pytest in the wheels's <requires_dist>, so it will be
1389 # already installed.
1390 #
1091 log(f'Not installing test packages because {PYODIDE_ROOT=}.') 1391 log(f'Not installing test packages because {PYODIDE_ROOT=}.')
1092 command = f'{pytest_options} {pytest_arg} -s' 1392 command = f'{pytest_options} {pytest_arg}'
1093 args = shlex.split(command) 1393 args = shlex.split(command)
1094 print(f'{PYODIDE_ROOT=} so calling pytest.main(args).') 1394 log(f'{PYODIDE_ROOT=} so calling pytest.main(args).')
1095 print(f'{command=}') 1395 log(f'{command=}')
1096 print(f'args are ({len(args)}):') 1396 log(f'args are ({len(args)}):')
1097 for arg in args: 1397 for arg in args:
1098 print(f' {arg!r}') 1398 log(f' {arg!r}')
1099 import pytest 1399 import pytest
1100 pytest.main(args) 1400 e = pytest.main(args)
1401 assert e == 0, f'pytest.main() failed: {e=}'
1101 return 1402 return
1102 1403
1103 if venv >= 2: 1404 if venv >= 2:
1104 run(f'pip install --upgrade {gh_release.test_packages}') 1405 run(f'pip install --upgrade {gh_release.test_packages}')
1105 else: 1406 else:
1161 1462
1162 command += f' {pytest_options} {pytest_arg}' 1463 command += f' {pytest_options} {pytest_arg}'
1163 1464
1164 # Always start by removing any test_*_fitz.py files. 1465 # Always start by removing any test_*_fitz.py files.
1165 for p in glob.glob(f'{pymupdf_dir_rel}/tests/test_*_fitz.py'): 1466 for p in glob.glob(f'{pymupdf_dir_rel}/tests/test_*_fitz.py'):
1166 print(f'Removing {p=}') 1467 log(f'Removing {p=}')
1167 os.remove(p) 1468 os.remove(p)
1168 if test_fitz: 1469 if test_fitz:
1169 # Create copies of each test file, modified to use `pymupdf` 1470 # Create copies of each test file, modified to use `pymupdf`
1170 # instead of `fitz`. 1471 # instead of `fitz`.
1171 for p in glob.glob(f'{pymupdf_dir_rel}/tests/test_*.py'): 1472 for p in glob.glob(f'{pymupdf_dir_rel}/tests/test_*.py'):
1173 # Don't recursively generate test_fitz_fitz_foo.py, 1474 # Don't recursively generate test_fitz_fitz_foo.py,
1174 # test_fitz_fitz_fitz_foo.py, ... etc. 1475 # test_fitz_fitz_fitz_foo.py, ... etc.
1175 continue 1476 continue
1176 branch, leaf = os.path.split(p) 1477 branch, leaf = os.path.split(p)
1177 p2 = f'{branch}/{leaf[:5]}fitz_{leaf[5:]}' 1478 p2 = f'{branch}/{leaf[:5]}fitz_{leaf[5:]}'
1178 print(f'Converting {p=} to {p2=}.') 1479 log(f'Converting {p=} to {p2=}.')
1179 with open(p, encoding='utf8') as f: 1480 with open(p, encoding='utf8') as f:
1180 text = f.read() 1481 text = f.read()
1181 text2 = re.sub("([^\'])\\bpymupdf\\b", '\\1fitz', text) 1482 text2 = re.sub("([^\'])\\bpymupdf\\b", '\\1fitz', text)
1182 if p.replace(os.sep, '/') == f'{pymupdf_dir_rel}/tests/test_docs_samples.py'.replace(os.sep, '/'): 1483 if p.replace(os.sep, '/') == f'{pymupdf_dir_rel}/tests/test_docs_samples.py'.replace(os.sep, '/'):
1183 assert text2 == text 1484 assert text2 == text