comparison scripts/test.py @ 3:2c135c81b16c

MERGE: upstream PyMuPDF 1.26.4 with MuPDF 1.26.7
author Franz Glasner <fzglas.hg@dom66.de>
date Mon, 15 Sep 2025 11:44:09 +0200
parents 1d09e1dec1d9
children a6bc019ac0b2
comparison
equal deleted inserted replaced
0:6015a75abc2d 3:2c135c81b16c
1 #! /usr/bin/env python3
2
3 '''Developer build/test script for PyMuPDF.
4
5 Examples:
6
7 ./PyMuPDF/scripts/test.py --m mupdf build test
8 Build and test with pre-existing local mupdf/ checkout.
9
10 ./PyMuPDF/scripts/test.py build test
11 Build and test with default internal download of mupdf.
12
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.
15
16 ./PyMuPDF/scripts/test.py -m 'git:--branch 1.26.x https://github.com/ArtifexSoftware/mupdf.git' build test
17 Build and test using internal checkout of mupdf 1.26.x branch from
18 Github.
19
20 Usage:
21
22 * Command line arguments are called parameters if they start with `-`,
23 otherwise they are called commands.
24 * Parameters are evaluated first in the order that they were specified.
25 * Then commands are run in the order in which they were specified.
26 * Usually command `test` would be specified after a `build`, `install` or
27 `wheel` command.
28 * Parameters and commands can be interleaved but it may be clearer to separate
29 them on the command line.
30
31 Other:
32
33 * If we are not already running inside a Python venv, we automatically create a
34 venv and re-run ourselves inside it.
35 * Build/wheel/install commands always install into the venv.
36 * Tests use whatever PyMuPDF/MuPDF is currently installed in the venv.
37 * We run tests with pytest.
38
39 * One can generate call traces by setting environment variables in debug
40 builds. For details see:
41 https://mupdf.readthedocs.io/en/latest/language-bindings.html#environmental-variables
42
43 Command line args:
44
45 -a <env_name>
46 Read next space-separated argument(s) from environmental variable
47 <env_name>.
48 * Does nothing if <env_name> is unset.
49 * Useful when running via Github action.
50
51 -b <build>
52 Set build type for `build` commands. `<build>` should be one of
53 'release', 'debug', 'memento'. [This makes `build` set environment
54 variable `PYMUPDF_SETUP_MUPDF_BUILD_TYPE`, which is used by PyMuPDF's
55 `setup.py`.]
56
57 --build-flavour <build_flavour>
58 Combination of 'p', 'b', 'd'. See ../setup.py's description of
59 PYMUPDF_SETUP_FLAVOUR. Default is 'pbd', i.e. self-contained PyMuPDF
60 wheels including MuPDF build-time files.
61
62 --build-isolation 0|1
63 If true (the default on non-OpenBSD systems), we let pip create and use
64 its own new venv to build PyMuPDF. Otherwise we force pip to use the
65 current venv.
66
67 --cibw-archs-linux <archs>
68 Set CIBW_ARCHS_LINUX, e.g. to `auto64 aarch64`. Default is `auto64` so
69 this allows control over whether to build linux-aarch64 wheels.
70
71 --cibw-name <cibw_name>
72 Name to use when installing cibuildwheel, e.g.:
73 --cibw-name cibuildwheel==3.0.0b1
74 Default is `cibuildwheel`, i.e. the current release.
75
76 --cibw-pyodide 0|1
77 Experimental, make `cibuild` command build a pyodide wheel.
78 2025-05-27: this fails when building mupdf C API - `ld -r -b binary
79 ...` 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)
81
82 --cibw-pyodide-version <cibw_pyodide_version>
83 Override default Pyodide version to use with `cibuildwheel` command. If
84 empty string we use cibuildwheel's default.
85
86 --cibw-release-1
87 Set up so that `cibw` builds all wheels except linux-aarch64, and sdist
88 if on Linux.
89
90 --cibw-release-2
91 Set up so that `cibw` builds only linux-aarch64 wheel.
92
93 -d
94 Equivalent to `-b debug`.
95
96 --dummy
97 Sets PYMUPDF_SETUP_DUMMY=1 which makes setup.py build a dummy wheel
98 with no content. For internal testing only.
99
100 -e <name>=<value>
101 Add to environment used in build and test commands. Can be specified
102 multiple times.
103
104 -f 0|1
105 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
110 --graal
111 Use graal - run inside a Graal VM instead of a Python venv.
112
113 As of 2025-08-04 we:
114 * Clone the latest pyenv and build it.
115 * Use pyenv to install graalpy.
116 * Use graalpy to create venv.
117
118 [After the first time, suggest `-v 1` to avoid delay from
119 updating/building pyenv and recreating the graal venv.]
120
121 --help
122 -h
123 Show help.
124
125 -I <implementations>
126 Set PyMuPDF implementations to test.
127 <implementations> must contain only these individual characters:
128 'r' - rebased.
129 'R' - rebased without optimisations.
130 Default is 'r'. Also see `PyMuPDF:tests/run_compound.py`.
131
132 -i <install_version>
133 Set version installed by the 'install' command.
134
135 -k <expression>
136 Specify which test(s) to run; passed straight through to pytest's `-k`.
137 For example `-k test_3354`.
138
139 -m <location> | --mupdf <location>
140 Location of local mupdf/ directory or 'git:...' to be used
141 when building PyMuPDF.
142
143 This sets environment variable PYMUPDF_SETUP_MUPDF_BUILD, which is used
144 by PyMuPDF/setup.py. If not specified PyMuPDF will download its default
145 mupdf .tgz.
146
147 Additionally if <location> starts with ':' we use the remaining text as
148 the branch name and add https://github.com/ArtifexSoftware/mupdf.git.
149
150 For example:
151
152 -m "git:--branch master https://github.com/ArtifexSoftware/mupdf.git"
153 -m :master
154
155 -m "git:--branch 1.26.x https://github.com/ArtifexSoftware/mupdf.git"
156 -m :1.26.x
157
158 --mupdf-clean 0|1
159 If 1 we do a clean MuPDF build.
160
161 -M 0|1
162 --build-mupdf 0|1
163 Whether to rebuild mupdf when we build PyMuPDF. Default is 1.
164
165 -o <os_names>
166 Control whether we do nothing on the current platform.
167 * <os_names> is a comma-separated list of names.
168 * If <os_names> is empty (the default), we always run normally.
169 * Otherwise we only run if an item in <os_names> matches (case
170 insensitive) platform.system().
171 * For example `-o linux,darwin` will do nothing unless on Linux or
172 MacOS.
173
174 -p <pytest-options>
175 Set pytest options; default is ''.
176
177 -P 0|1
178 If 1, automatically install required system packages such as
179 Valgrind. Default is 0.
180
181 --pybind 0|1
182 Experimental, for investigating
183 https://github.com/pymupdf/PyMuPDF/issues/3869. Runs run basic code
184 inside C++ pybind. Requires `sudo apt install pybind11-dev` or similar.
185
186 --pyodide-build-version <version>
187 Version of Python package pyodide-build to use with `pyodide` command.
188
189 If None (the default) `pyodide` uses the latest available version.
190 2025-02-13: pyodide_build_version='0.29.3' works.
191
192 -s 0 | 1
193 If 1 (the default), build with Python Limited API/Stable ABI.
194 [This simply sSets $PYMUPDF_SETUP_PY_LIMITED_API, which is used by
195 PyMuPDF/setup.py.]
196
197 --show-args:
198 Show sys.argv and exit. For debugging.
199
200 --sync-paths
201 Do not run anything, instead write required files/directories/checkouts
202 to stdout, one per line. This is to help with automated running on
203 remote machines.
204
205 --system-site-packages 0|1
206 If 1, use `--system-site-packages` when creating venv. Defaults is 0.
207
208 --swig <swig>
209 Use <swig> instead of the `swig` command.
210
211 Unix only:
212 Clone/update/build swig from a git repository using 'git:' prefix.
213
214 We default to https://github.com/swig/swig.git branch master, so these
215 are all equivalent:
216
217 --swig 'git:--branch master https://github.com/swig/swig.git'
218 --swig 'git:--branch master'
219 --swig git:
220
221 2025-08-18: This fixes building with py_limited_api on python-3.13.
222
223 --swig-quick 0|1
224 If 1 and `--swig` starts with 'git:', we do not update/build swig if
225 already present.
226
227 See description of PYMUPDF_SETUP_SWIG_QUICK in setup.py.
228
229 -t <names>
230 Pytest test names, comma-separated. Should be relative to PyMuPDF
231 directory. For example:
232 -t tests/test_general.py
233 -t tests/test_general.py::test_subset_fonts
234 To specify multiple tests, use comma-separated list and/or multiple `-t
235 <names>` args.
236
237 --timeout <seconds>
238 Sets timeout when running tests.
239
240 -T <prefix>
241 Use specified prefix when running pytest, must be one of:
242 gdb
243 helgrind
244 vagrind
245
246 -v <venv>
247 venv is:
248 0 - do not use a venv.
249 1 - Use venv. If it already exists, we assume the existing directory
250 was created by us earlier and is a valid venv containing all
251 necessary packages; this saves a little time.
252 2 - Use venv.
253 3 - Use venv but delete it first if it already exists.
254 The default is 2.
255
256 Commands:
257
258 build
259 Builds and installs PyMuPDF into venv, using `pip install .../PyMuPDF`.
260
261 buildtest
262 Same as 'build test'.
263
264 cibw
265 Build and test PyMuPDF wheel(s) using cibuildwheel. Wheels are placed
266 in directory `wheelhouse`.
267 * We do not attempt to install wheels.
268 * So it is generally not useful to do `cibw test`.
269
270 If CIBW_BUILD is unset, we set it as follows:
271 * On Github we build and test all supported Python versions.
272 * Otherwise we build and test the current Python version only.
273
274 If CIBW_ARCHS is unset we set $CIBW_ARCHS_WINDOWS, $CIBW_ARCHS_MACOS
275 and $CIBW_ARCHS_LINUX to auto64 if they are unset.
276
277 install <pymupdf>
278 Install with `pip install --force-reinstall <pymupdf>`.
279
280 pyodide
281 Build Pyodide wheel. We clone `emsdk.git`, set it up, and run
282 `pyodide build`. This runs our setup.py with CC etc set up
283 to create Pyodide binaries in a wheel called, for example,
284 `PyMuPDF-1.23.2-cp311-none-emscripten_3_1_32_wasm32.whl`.
285
286 It seems that sys.version must match the Python version inside emsdk;
287 as of 2025-02-14 this is 3.12. Otherwise we get build errors such as:
288 [wasm-validator error in function 723] unexpected false: all used features should be allowed, on ...
289
290 test
291 Runs PyMuPDF's pytest tests. Default is to test rebased and unoptimised
292 rebased; use `-i` to change this.
293
294 wheel
295 Build and install wheel.
296
297
298 Environment:
299 PYMUDF_SCRIPTS_TEST_options
300 Is prepended to command line args.
301 '''
302
303 import glob
304 import os
305 import platform
306 import re
307 import shlex
308 import shutil
309 import subprocess
310 import sys
311 import textwrap
312
313
314 pymupdf_dir_abs = os.path.abspath( f'{__file__}/../..')
315
316 try:
317 sys.path.insert(0, pymupdf_dir_abs)
318 import pipcl
319 finally:
320 del sys.path[0]
321
322 try:
323 sys.path.insert(0, f'{pymupdf_dir_abs}/scripts')
324 import gh_release
325 finally:
326 del sys.path[0]
327
328
329 pymupdf_dir = pipcl.relpath(pymupdf_dir_abs)
330
331 log = pipcl.log0
332 run = pipcl.run
333
334
335 def main(argv):
336
337 if github_workflow_unimportant():
338 return
339
340 build_isolation = None
341 cibw_name = None
342 cibw_pyodide = None
343 cibw_pyodide_version = None
344 commands = list()
345 env_extra = dict()
346 graal = False
347 implementations = 'r'
348 install_version = None
349 mupdf_sync = None
350 os_names = list()
351 system_packages = False
352 pybind = False
353 pyodide_build_version = None
354 pytest_options = ''
355 pytest_prefix = None
356 cibw_sdist = None
357 show_args = False
358 show_help = False
359 sync_paths = False
360 system_site_packages = False
361 swig = None
362 swig_quick = None
363 test_fitz = False
364 test_names = list()
365 test_timeout = None
366 valgrind = False
367 warnings = list()
368 venv = 2
369
370 options = os.environ.get('PYMUDF_SCRIPTS_TEST_options', '')
371 options = shlex.split(options)
372
373 # Parse args and update the above state. We do this before moving into a
374 # venv, partly so we can return errors immediately.
375 #
376 args = iter(options + argv[1:])
377 i = 0
378 while 1:
379 try:
380 arg = next(args)
381 except StopIteration:
382 arg = None
383 break
384
385 if 0:
386 pass
387
388 elif arg == '-a':
389 _name = next(args)
390 _value = os.environ.get(_name, '')
391 _args = shlex.split(_value) + list(args)
392 args = iter(_args)
393
394 elif arg == '-b':
395 env_extra['PYMUPDF_SETUP_MUPDF_BUILD_TYPE'] = next(args)
396
397 elif arg == '--build-flavour':
398 env_extra['PYMUPDF_SETUP_FLAVOUR'] = next(args)
399
400 elif arg == '--build-isolation':
401 build_isolation = int(next(args))
402
403 elif arg == '--cibw-pyodide-version':
404 cibw_pyodide_version = next(args)
405
406 elif arg == '--cibw-release-1':
407 cibw_sdist = True
408 env_extra['CIBW_ARCHS_LINUX'] = 'auto64'
409 env_extra['CIBW_ARCHS_MACOS'] = 'auto64'
410 env_extra['CIBW_ARCHS_WINDOWS'] = 'auto' # win32 and win64.
411 env_extra['CIBW_SKIP'] = 'pp* *i686 cp36* cp37* *musllinux*aarch64*'
412
413 elif arg == '--cibw-release-2':
414 env_extra['CIBW_ARCHS_LINUX'] = 'aarch64'
415 # Testing only first and last python versions because otherwise
416 # Github times out after 6h.
417 env_extra['CIBW_BUILD'] = 'cp39* cp313*'
418 os_names = ['linux']
419
420 elif arg == '--cibw-archs-linux':
421 env_extra['CIBW_ARCHS_LINUX'] = next(args)
422
423 elif arg == '--cibw-name':
424 cibw_name = next(args)
425
426 elif arg == '--cibw-pyodide':
427 cibw_pyodide = next(args)
428
429 elif arg == '-d':
430 env_extra['PYMUPDF_SETUP_MUPDF_BUILD_TYPE'] = 'debug'
431
432 elif arg == '--dummy':
433 env_extra['PYMUPDF_SETUP_DUMMY'] = '1'
434 env_extra['CIBW_TEST_COMMAND'] = ''
435
436 elif arg == '-e':
437 _nv = next(args)
438 assert '=' in _nv, f'-e <name>=<value> does not contain "=": {_nv!r}'
439 _name, _value = _nv.split('=', 1)
440 env_extra[_name] = _value
441
442 elif arg == '-f':
443 test_fitz = int(next(args))
444
445 elif arg == '--graal':
446 graal = True
447
448 elif arg in ('-h', '--help'):
449 show_help = True
450
451 elif arg == '-i':
452 install_version = next(args)
453
454 elif arg == '-I':
455 implementations = next(args)
456
457 elif arg == '-k':
458 pytest_options += f' -k {shlex.quote(next(args))}'
459
460 elif arg in ('-m', '--mupdf'):
461 _mupdf = next(args)
462 if _mupdf == '-':
463 _mupdf = None
464 elif _mupdf.startswith(':'):
465 _branch = _mupdf[1:]
466 _mupdf = 'git:--branch {_branch} https://github.com/ArtifexSoftware/mupdf.git'
467 os.environ['PYMUPDF_SETUP_MUPDF_BUILD'] = _mupdf
468 elif _mupdf.startswith('git:') or '://' in _mupdf:
469 os.environ['PYMUPDF_SETUP_MUPDF_BUILD'] = _mupdf
470 else:
471 assert os.path.isdir(_mupdf), f'Not a directory: {_mupdf=}'
472 os.environ['PYMUPDF_SETUP_MUPDF_BUILD'] = os.path.abspath(_mupdf)
473 mupdf_sync = _mupdf
474
475 elif arg == '--mupdf-clean':
476 env_extra['PYMUPDF_SETUP_MUPDF_CLEAN']=next(args)
477
478 elif arg in ('-M', '--build-mupdf'):
479 env_extra['PYMUPDF_SETUP_MUPDF_REBUILD'] = next(args)
480
481 elif arg == '-o':
482 os_names += next(args).split(',')
483
484 elif arg == '-p':
485 pytest_options += f' {next(args)}'
486
487 elif arg == '-P':
488 system_packages = int(next(args))
489
490 elif arg == '--pybind':
491 pybind = int(next(args))
492
493 elif arg == '--pyodide-build-version':
494 pyodide_build_version = next(args)
495
496 elif arg == '-s':
497 _value = next(args)
498 assert _value in ('0', '1'), f'`-s` must be followed by `0` or `1`, not {_value=}.'
499 env_extra['PYMUPDF_SETUP_PY_LIMITED_API'] = _value
500
501 elif arg == '--show-args':
502 show_args = 1
503 elif arg == '--sync-paths':
504 sync_paths = True
505
506 elif arg == '--system-site-packages':
507 system_site_packages = int(next(args))
508
509 elif arg == '--swig':
510 swig = next(args)
511
512 elif arg == '--swig-quick':
513 swig_quick = int(next(args))
514
515 elif arg == '-t':
516 test_names += next(args).split(',')
517
518 elif arg == '--timeout':
519 test_timeout = float(next(args))
520
521 elif arg == '-T':
522 pytest_prefix = next(args)
523 assert pytest_prefix in ('gdb', 'helgrind', 'valgrind'), \
524 f'Unrecognised {pytest_prefix=}, should be one of: gdb valgrind helgrind.'
525
526 elif arg == '-v':
527 venv = int(next(args))
528 assert venv in (0, 1, 2, 3), f'Invalid {venv=} should be 0, 1, 2 or 3.'
529
530 elif arg in ('build', 'cibw', 'install', 'pyodide', 'test', 'wheel'):
531 commands.append(arg)
532
533 elif arg == 'buildtest':
534 commands += ['build', 'test']
535
536 else:
537 assert 0, f'Unrecognised option/command: {arg=}.'
538
539 # Handle special args --sync-paths, -h, -v, -o first.
540 #
541 if sync_paths:
542 # Just print required files, directories and checkouts.
543 print(pymupdf_dir)
544 if mupdf_sync:
545 print(mupdf_sync)
546 return
547
548 if show_help:
549 print(__doc__)
550 return
551
552 if show_args:
553 print(f'sys.argv ({len(sys.argv)}):')
554 for arg in sys.argv:
555 print(f' {arg!r}')
556 return
557
558 if os_names:
559 if platform.system().lower() not in os_names:
560 log(f'Not running because {platform.system().lower()=} not in {os_names=}')
561 return
562
563 if commands:
564 if venv:
565 # Rerun ourselves inside a venv if not already in a venv.
566 if not venv_in():
567 if graal:
568 # 2025-07-24: We need the latest pyenv.
569 graalpy = 'graalpy-24.2.1'
570 venv_name = f'venv-pymupdf-{graalpy}'
571 pyenv_dir = f'{pymupdf_dir_abs}/pyenv-git'
572 os.environ['PYENV_ROOT'] = pyenv_dir
573 os.environ['PATH'] = f'{pyenv_dir}/bin:{os.environ["PATH"]}'
574 os.environ['PIPCL_GRAAL_PYTHON'] = sys.executable
575
576 if venv >= 3:
577 shutil.rmtree(venv_name, ignore_errors=1)
578 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.')
580 else:
581 pipcl.git_get('https://github.com/pyenv/pyenv.git', pyenv_dir, branch='master')
582 run(f'cd {pyenv_dir} && src/configure && make -C src')
583 run(f'which pyenv')
584 run(f'pyenv install -v -s {graalpy}')
585 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)}',
587 check=False,
588 )
589 else:
590 venv_name = f'venv-pymupdf-{platform.python_version()}-{int.bit_length(sys.maxsize+1)}'
591 e = venv_run(
592 sys.argv,
593 venv_name,
594 recreate=(venv>=2),
595 clean=(venv>=3),
596 )
597 sys.exit(e)
598 else:
599 log(f'Warning, no commands specified so nothing to do.')
600
601 # Clone/update/build swig if specified.
602 swig_binary = pipcl.swig_get(swig, swig_quick)
603 if swig_binary:
604 os.environ['PYMUPDF_SETUP_SWIG'] = swig_binary
605
606 # Handle commands.
607 #
608 have_installed = False
609 for command in commands:
610 log(f'### {command=}.')
611 if 0:
612 pass
613
614 elif command in ('build', 'wheel'):
615 build(
616 env_extra,
617 build_isolation=build_isolation,
618 venv=venv,
619 wheel=(command=='wheel'),
620 )
621 have_installed = True
622
623 elif command == 'cibw':
624 # Build wheel(s) with cibuildwheel.
625 if cibw_pyodide and env_extra.get('CIBW_BUILD') is None:
626 assert 0, f'Need a Python version for Pyodide.'
627 CIBW_BUILD = 'cp312*'
628 env_extra['CIBW_BUILD'] = CIBW_BUILD
629 log(f'Defaulting to {CIBW_BUILD=} for Pyodide.')
630 #if cibw_pyodide_version == None:
631 # cibw_pyodide_version = '0.28.0'
632 cibuildwheel(
633 env_extra,
634 cibw_name or 'cibuildwheel',
635 cibw_pyodide,
636 cibw_pyodide_version,
637 cibw_sdist,
638 )
639
640 elif command == 'install':
641 p = 'pymupdf'
642 if install_version:
643 if not install_version.startswith(('==', '>=', '>')):
644 p = f'{p}=='
645 p = f'{p}{install_version}'
646 run(f'pip install --force-reinstall {p}')
647 have_installed = True
648
649 elif command == 'test':
650 if not have_installed:
651 log(f'## Warning: have not built/installed PyMuPDF; testing whatever is already installed.')
652 test(
653 env_extra=env_extra,
654 implementations=implementations,
655 test_names=test_names,
656 pytest_options=pytest_options,
657 test_timeout=test_timeout,
658 pytest_prefix=pytest_prefix,
659 test_fitz=test_fitz,
660 pybind=pybind,
661 system_packages=system_packages,
662 venv=venv,
663 )
664
665 elif command == 'pyodide':
666 build_pyodide_wheel(pyodide_build_version=pyodide_build_version)
667
668 else:
669 assert 0, f'{command=}'
670
671
672 def get_env_bool(name, default=0):
673 v = os.environ.get(name)
674 if v in ('1', 'true'):
675 return 1
676 elif v in ('0', 'false'):
677 return 0
678 elif v is None:
679 return default
680 else:
681 assert 0, f'Bad environ {name=} {v=}'
682
683 def show_help():
684 print(__doc__)
685 print(venv_info())
686
687
688 def github_workflow_unimportant():
689 '''
690 Returns true if we are running a Github scheduled workflow but in a
691 repository not called 'PyMuPDF'. This can be used to avoid consuming
692 unnecessary Github minutes running workflows on non-main repositories such
693 as ArtifexSoftware/PyMuPDF-julian.
694 '''
695 GITHUB_EVENT_NAME = os.environ.get('GITHUB_EVENT_NAME')
696 GITHUB_REPOSITORY = os.environ.get('GITHUB_REPOSITORY')
697 if GITHUB_EVENT_NAME == 'schedule' and GITHUB_REPOSITORY != 'pymupdf/PyMuPDF':
698 log(f'## This is an unimportant Github workflow: a scheduled event, not in the main repository `pymupdf/PyMuPDF`.')
699 log(f'## {GITHUB_EVENT_NAME=}.')
700 log(f'## {GITHUB_REPOSITORY=}.')
701 return True
702
703 def venv_info(pytest_args=None):
704 '''
705 Returns string containing information about the venv we use and how to
706 run tests manually. If specified, `pytest_args` contains the pytest args,
707 otherwise we use an example.
708 '''
709 pymupdf_dir_rel = gh_release.relpath(pymupdf_dir)
710 ret = f'Name of venv: {gh_release.venv_name}\n'
711 if pytest_args is None:
712 pytest_args = f'{pymupdf_dir_rel}/tests/test_general.py::test_subset_fonts'
713 if platform.system() == 'Windows':
714 ret += textwrap.dedent(f'''
715 Rerun tests manually with rebased implementation:
716 Enter venv:
717 {gh_release.venv_name}\\Scripts\\activate
718 Run specific test in venv:
719 {gh_release.venv_name}\\Scripts\\python -m pytest {pytest_args}
720 ''')
721 else:
722 ret += textwrap.dedent(f'''
723 Rerun tests manually with rebased implementation:
724 Enter venv and run specific test, also under gdb:
725 . {gh_release.venv_name}/bin/activate
726 python -m pytest {pytest_args}
727 gdb --args python -m pytest {pytest_args}
728 Run without explicitly entering venv, also under gdb:
729 ./{gh_release.venv_name}/bin/python -m pytest {pytest_args}
730 gdb --args ./{gh_release.venv_name}/bin/python -m pytest {pytest_args}
731 ''')
732 return ret
733
734
735 def build(
736 env_extra,
737 *,
738 build_isolation,
739 venv,
740 wheel,
741 ):
742 print(f'{build_isolation=}')
743
744 if build_isolation is None:
745 # 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
747 # installing other required packages.
748 build_isolation = False if platform.system() == 'OpenBSD' else True
749
750 if build_isolation:
751 # This is the default on non-OpenBSD.
752 build_isolation_text = ''
753 else:
754 # Not using build isolation - i.e. pip will not be using its own clean
755 # venv, so we need to explicitly install required packages. Manually
756 # install required packages from pyproject.toml.
757 sys.path.insert(0, os.path.abspath(f'{__file__}/../..'))
758 import setup
759 names = setup.get_requires_for_build_wheel()
760 del sys.path[0]
761 if names:
762 names = ' '.join(names)
763 if venv == 2:
764 run( f'python -m pip install --upgrade {names}')
765 else:
766 log(f'{venv=}: Not installing packages with pip: {names}')
767 build_isolation_text = ' --no-build-isolation'
768
769 if wheel:
770 new_files = pipcl.NewFiles(f'wheelhouse/*.whl')
771 run(f'pip wheel{build_isolation_text} -w wheelhouse -v {pymupdf_dir_abs}', env_extra=env_extra)
772 wheel = new_files.get_one()
773 run(f'pip install --force-reinstall {wheel}')
774 else:
775 run(f'pip install{build_isolation_text} -v --force-reinstall {pymupdf_dir_abs}', env_extra=env_extra)
776
777
778 def cibuildwheel(env_extra, cibw_name, cibw_pyodide, cibw_pyodide_version, cibw_sdist):
779
780 if cibw_sdist and platform.system() == 'Linux':
781 log(f'Building sdist.')
782 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')
784 log(f'{sdists=}')
785 assert sdists
786
787 run(f'pip install --upgrade --force-reinstall {cibw_name}')
788
789 # Some general flags.
790 if 'CIBW_BUILD_VERBOSITY' not in env_extra:
791 env_extra['CIBW_BUILD_VERBOSITY'] = '1'
792 if 'CIBW_SKIP' not in env_extra:
793 env_extra['CIBW_SKIP'] = 'pp* *i686 cp36* cp37* *musllinux* *-win32 *-aarch64'
794
795 # Set what wheels to build, if not already specified.
796 if 'CIBW_ARCHS' not in env_extra:
797 if 'CIBW_ARCHS_WINDOWS' not in env_extra:
798 env_extra['CIBW_ARCHS_WINDOWS'] = 'auto64'
799
800 if 'CIBW_ARCHS_MACOS' not in env_extra:
801 env_extra['CIBW_ARCHS_MACOS'] = 'auto64'
802
803 if 'CIBW_ARCHS_LINUX' not in env_extra:
804 env_extra['CIBW_ARCHS_LINUX'] = 'auto64'
805
806 # Tell cibuildwheel not to use `auditwheel` on Linux and MacOS,
807 # because it cannot cope with us deliberately having required
808 # libraries in different wheel - specifically in the PyMuPDF wheel.
809 #
810 # We cannot use a subset of auditwheel's functionality
811 # with `auditwheel addtag` because it says `No tags
812 # to be added` and terminates with non-zero. See:
813 # https://github.com/pypa/auditwheel/issues/439.
814 #
815 env_extra['CIBW_REPAIR_WHEEL_COMMAND_LINUX'] = ''
816 env_extra['CIBW_REPAIR_WHEEL_COMMAND_MACOS'] = ''
817
818 # Tell cibuildwheel how to test PyMuPDF.
819 if 'CIBW_TEST_COMMAND' not in env_extra:
820 env_extra['CIBW_TEST_COMMAND'] = f'python {{project}}/scripts/test.py test'
821
822 # Specify python versions.
823 CIBW_BUILD = env_extra.get('CIBW_BUILD')
824 log(f'{CIBW_BUILD=}')
825 if CIBW_BUILD is None:
826 if os.environ.get('GITHUB_ACTIONS') == 'true':
827 # Build/test all supported Python versions.
828 CIBW_BUILD = 'cp39* cp310* cp311* cp312* cp313*'
829 else:
830 # Build/test current Python only.
831 v = platform.python_version_tuple()[:2]
832 log(f'{v=}')
833 CIBW_BUILD = f'cp{"".join(v)}*'
834
835 cibw_pyodide_args = ''
836 if cibw_pyodide:
837 cibw_pyodide_args = ' --platform pyodide'
838 env_extra['HAVE_LIBCRYPTO'] = 'no'
839 env_extra['PYMUPDF_SETUP_MUPDF_TESSERACT'] = '0'
840 if cibw_pyodide_version:
841 # 2025-07-21: there is no --pyodide-version option so we set
842 # CIBW_PYODIDE_VERSION.
843 env_extra['CIBW_PYODIDE_VERSION'] = cibw_pyodide_version
844 env_extra['CIBW_ENABLE'] = 'pyodide-prerelease'
845
846 # Pass all the environment variables we have set, to Linux
847 # docker. Note that this will miss any settings in the original
848 # environment.
849 env_extra['CIBW_ENVIRONMENT_PASS_LINUX'] = ' '.join(sorted(env_extra.keys()))
850
851 # Build for lowest (assumed first) Python version.
852 #
853 CIBW_BUILD_0 = CIBW_BUILD.split()[0]
854 log(f'Building for first Python version {CIBW_BUILD_0}.')
855 env_extra['CIBW_BUILD'] = CIBW_BUILD_0
856 run(f'cd {pymupdf_dir} && cibuildwheel{cibw_pyodide_args}', env_extra=env_extra)
857
858 # Tell cibuildwheel to build and test all specified Python versions; it
859 # will notice that the wheel we built above supports all versions of
860 # Python, so will not actually do any builds here.
861 #
862 env_extra['CIBW_BUILD'] = CIBW_BUILD
863 run(f'cd {pymupdf_dir} && cibuildwheel{cibw_pyodide_args}', env_extra=env_extra)
864 run(f'ls -ld {pymupdf_dir}/wheelhouse/*')
865
866
867 def build_pyodide_wheel(pyodide_build_version=None):
868 '''
869 Build Pyodide wheel.
870
871 This runs `pyodide build` inside the PyMuPDF directory, which in turn runs
872 setup.py in a Pyodide build environment.
873 '''
874 log(f'## Building Pyodide wheel.')
875
876 # Our setup.py does not know anything about Pyodide; we set a few
877 # required environmental variables here.
878 #
879 env_extra = dict()
880
881 # Disable libcrypto because not available in Pyodide.
882 env_extra['HAVE_LIBCRYPTO'] = 'no'
883
884 # Tell MuPDF to build for Pyodide.
885 env_extra['OS'] = 'pyodide'
886
887 # Build a single wheel without a separate PyMuPDFb wheel.
888 env_extra['PYMUPDF_SETUP_FLAVOUR'] = 'pb'
889
890 # 2023-08-30: We set PYMUPDF_SETUP_MUPDF_BUILD_TESSERACT=0 because
891 # otherwise mupdf thirdparty/tesseract/src/ccstruct/dppoint.cpp fails to
892 # build because `#include "errcode.h"` finds a header inside emsdk. This is
893 # pyodide bug https://github.com/pyodide/pyodide/issues/3839. It's fixed in
894 # https://github.com/pyodide/pyodide/pull/3866 but the fix has not reached
895 # pypi.org's pyodide-build package. E.g. currently in tag 0.23.4, but
896 # current devuan pyodide-build is pyodide_build-0.23.4.
897 #
898 env_extra['PYMUPDF_SETUP_MUPDF_TESSERACT'] = '0'
899 setup = pyodide_setup(pymupdf_dir, pyodide_build_version=pyodide_build_version)
900 command = f'{setup} && echo "### Running pyodide build" && pyodide build --exports whole_archive'
901
902 command = command.replace(' && ', '\n && ')
903
904 run(command, env_extra=env_extra)
905
906 # Copy wheel into `wheelhouse/` so it is picked up as a workflow
907 # artifact.
908 #
909 run(f'ls -l {pymupdf_dir}/dist/')
910 run(f'mkdir -p {pymupdf_dir}/wheelhouse && cp -p {pymupdf_dir}/dist/* {pymupdf_dir}/wheelhouse/')
911 run(f'ls -l {pymupdf_dir}/wheelhouse/')
912
913
914 def pyodide_setup(
915 directory,
916 clean=False,
917 pyodide_build_version=None,
918 ):
919 '''
920 Returns a command that will set things up for a pyodide build.
921
922 Args:
923 directory:
924 Our command cd's into this directory.
925 clean:
926 If true we create an entirely new environment. Otherwise
927 we reuse any existing emsdk repository and venv.
928 pyodide_build_version:
929 Version of Python package pyodide-build; if None we use latest
930 available version.
931 2025-02-13: pyodide_build_version='0.29.3' works.
932
933 The returned command does the following:
934
935 * Checkout latest emsdk from https://github.com/emscripten-core/emsdk.git:
936 * Clone emsdk repository to `emsdk` if not already present.
937 * Run `git pull -r` inside emsdk checkout.
938 * Create venv `venv_pyodide_<python_version>` if not already present.
939 * Activate venv `venv_pyodide_<python_version>`.
940 * Install/upgrade package `pyodide-build`.
941 * Run emsdk install scripts and enter emsdk environment.
942
943 Example usage in a build function:
944
945 command = pyodide_setup()
946 command += ' && pyodide build --exports pyinit'
947 subprocess.run(command, shell=1, check=1)
948 '''
949
950 pv = platform.python_version_tuple()[:2]
951 assert pv == ('3', '12'), f'Pyodide builds need to be run with Python-3.12 but current Python is {platform.python_version()}.'
952 command = f'cd {directory}'
953
954 # Clone/update emsdk. We always use the latest emsdk with `git pull`.
955 #
956 # 2025-02-13: this works: 2514ec738de72cebbba7f4fdba0cf2fabcb779a5
957 #
958 dir_emsdk = 'emsdk'
959 if clean:
960 shutil.rmtree(dir_emsdk, ignore_errors=1)
961 # 2024-06-25: old `.pyodide-xbuildenv` directory was breaking build, so
962 # important to remove it here.
963 shutil.rmtree('.pyodide-xbuildenv', ignore_errors=1)
964 if not os.path.exists(f'{directory}/{dir_emsdk}'):
965 command += f' && echo "### Cloning emsdk.git"'
966 command += f' && git clone https://github.com/emscripten-core/emsdk.git {dir_emsdk}'
967 command += f' && echo "### Updating checkout {dir_emsdk}"'
968 command += f' && (cd {dir_emsdk} && git pull -r)'
969 command += f' && echo "### Checkout {dir_emsdk} is:"'
970 command += f' && (cd {dir_emsdk} && git show -s --oneline)'
971
972 # Create and enter Python venv.
973 #
974 python = sys.executable
975 venv_pyodide = f'venv_pyodide_{sys.version_info[0]}.{sys.version_info[1]}'
976
977 if not os.path.exists( f'{directory}/{venv_pyodide}'):
978 command += f' && echo "### Creating venv {venv_pyodide}"'
979 command += f' && {python} -m venv {venv_pyodide}'
980 command += f' && . {venv_pyodide}/bin/activate'
981 command += f' && echo "### Installing Python packages."'
982 command += f' && python -m pip install --upgrade pip wheel pyodide-build'
983 if pyodide_build_version:
984 command += f'=={pyodide_build_version}'
985
986 # Run emsdk install scripts and enter emsdk environment.
987 #
988 command += f' && cd {dir_emsdk}'
989 command += ' && PYODIDE_EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version)'
990 command += ' && echo "### PYODIDE_EMSCRIPTEN_VERSION is: $PYODIDE_EMSCRIPTEN_VERSION"'
991 command += ' && echo "### Running ./emsdk install"'
992 command += ' && ./emsdk install ${PYODIDE_EMSCRIPTEN_VERSION}'
993 command += ' && echo "### Running ./emsdk activate"'
994 command += ' && ./emsdk activate ${PYODIDE_EMSCRIPTEN_VERSION}'
995 command += ' && echo "### Running ./emsdk_env.sh"'
996 command += ' && . ./emsdk_env.sh' # Need leading `./` otherwise weird 'Not found' error.
997
998 command += ' && cd ..'
999 return command
1000
1001
1002 def test(
1003 *,
1004 env_extra,
1005 implementations,
1006 venv=False,
1007 test_names=None,
1008 pytest_options=None,
1009 test_timeout=None,
1010 pytest_prefix=None,
1011 test_fitz=True,
1012 pytest_k=None,
1013 pybind=False,
1014 system_packages=False,
1015 ):
1016 if pybind:
1017 cpp_path = 'pymupdf_test_pybind.cpp'
1018 cpp_exe = 'pymupdf_test_pybind.exe'
1019 cpp = textwrap.dedent('''
1020 #include <pybind11/embed.h>
1021
1022 int main()
1023 {
1024 pybind11::scoped_interpreter guard{};
1025 pybind11::exec(R"(
1026 print('Hello world', flush=1)
1027 import pymupdf
1028 pymupdf.JM_mupdf_show_warnings = 1
1029 print(f'{pymupdf.version=}', flush=1)
1030 doc = pymupdf.Document()
1031 pymupdf.mupdf.fz_warn('Dummy warning.')
1032 pymupdf.mupdf.fz_warn('Dummy warning.')
1033 pymupdf.mupdf.fz_warn('Dummy warning.')
1034 print(f'{doc=}', flush=1)
1035 )");
1036 }
1037 ''')
1038 def fs_read(path):
1039 try:
1040 with open(path) as f:
1041 return f.read()
1042 except Exception:
1043 return
1044 def fs_remove(path):
1045 try:
1046 os.remove(path)
1047 except Exception:
1048 pass
1049 cpp_existing = fs_read(cpp_path)
1050 if cpp == cpp_existing:
1051 log(f'Not creating {cpp_exe} because unchanged: {cpp_path}')
1052 else:
1053 with open(cpp_path, 'w') as f:
1054 f.write(cpp)
1055 def getmtime(path):
1056 try:
1057 return os.path.getmtime(path)
1058 except Exception:
1059 return 0
1060 python_config = f'{os.path.realpath(sys.executable)}-config'
1061 # `--embed` adds `-lpython3.11` to the link command, which appears to
1062 # be necessary when building an executable.
1063 flags = run(f'{python_config} --cflags --ldflags --embed', capture=1)
1064 build_command = f'c++ {cpp_path} -o {cpp_exe} -g -W -Wall {flags}'
1065 build_path = f'{cpp_exe}.cmd'
1066 build_command_prev = fs_read(build_path)
1067 if build_command != build_command_prev or getmtime(cpp_path) >= getmtime(cpp_exe):
1068 fs_remove(build_path)
1069 run(build_command)
1070 with open(build_path, 'w') as f:
1071 f.write(build_command)
1072 run(f'./{cpp_exe}')
1073 return
1074
1075 pymupdf_dir_rel = gh_release.relpath(pymupdf_dir)
1076 if not pytest_options and pytest_prefix == 'valgrind':
1077 pytest_options = '-sv'
1078 if pytest_k:
1079 pytest_options += f' -k {shlex.quote(pytest_k)}'
1080 pytest_arg = ''
1081 if test_names:
1082 for test_name in test_names:
1083 pytest_arg += f' {pymupdf_dir_rel}/{test_name}'
1084 else:
1085 pytest_arg += f' {pymupdf_dir_rel}/tests'
1086 python = gh_release.relpath(sys.executable)
1087 log('Running tests with tests/run_compound.py and pytest.')
1088
1089 PYODIDE_ROOT = os.environ.get('PYODIDE_ROOT')
1090 if PYODIDE_ROOT is not None:
1091 log(f'Not installing test packages because {PYODIDE_ROOT=}.')
1092 command = f'{pytest_options} {pytest_arg} -s'
1093 args = shlex.split(command)
1094 print(f'{PYODIDE_ROOT=} so calling pytest.main(args).')
1095 print(f'{command=}')
1096 print(f'args are ({len(args)}):')
1097 for arg in args:
1098 print(f' {arg!r}')
1099 import pytest
1100 pytest.main(args)
1101 return
1102
1103 if venv >= 2:
1104 run(f'pip install --upgrade {gh_release.test_packages}')
1105 else:
1106 log(f'{venv=}: Not installing test packages: {gh_release.test_packages}')
1107 run_compound_args = ''
1108
1109 if implementations:
1110 run_compound_args += f' -i {implementations}'
1111
1112 if test_timeout:
1113 run_compound_args += f' -t {test_timeout}'
1114
1115 if pytest_prefix in ('valgrind', 'helgrind'):
1116 if system_packages:
1117 log('Installing valgrind.')
1118 run(f'sudo apt update')
1119 run(f'sudo apt install --upgrade valgrind')
1120 run(f'valgrind --version')
1121
1122 command = f'{python} {pymupdf_dir_rel}/tests/run_compound.py{run_compound_args}'
1123
1124 if pytest_prefix is None:
1125 pass
1126 elif pytest_prefix == 'gdb':
1127 command += ' gdb --args'
1128 elif pytest_prefix == 'valgrind':
1129 env_extra['PYMUPDF_RUNNING_ON_VALGRIND'] = '1'
1130 env_extra['PYTHONMALLOC'] = 'malloc'
1131 command += (
1132 f' valgrind'
1133 f' --suppressions={pymupdf_dir_abs}/valgrind.supp'
1134 f' --trace-children=no'
1135 f' --num-callers=20'
1136 f' --error-exitcode=100'
1137 f' --errors-for-leak-kinds=none'
1138 f' --fullpath-after='
1139 )
1140 elif pytest_prefix == 'helgrind':
1141 env_extra['PYMUPDF_RUNNING_ON_VALGRIND'] = '1'
1142 env_extra['PYTHONMALLOC'] = 'malloc'
1143 command = (
1144 f' valgrind'
1145 f' --tool=helgrind'
1146 f' --trace-children=no'
1147 f' --num-callers=20'
1148 f' --error-exitcode=100'
1149 f' --fullpath-after='
1150 )
1151 else:
1152 assert 0, f'Unrecognised {pytest_prefix=}'
1153
1154 if platform.system() == 'Windows':
1155 # `python -m pytest` doesn't seem to work.
1156 command += ' pytest'
1157 else:
1158 # On OpenBSD `pip install pytest` doesn't seem to install the pytest
1159 # command, so we use `python -m pytest ...`.
1160 command += f' {python} -m pytest'
1161
1162 command += f' {pytest_options} {pytest_arg}'
1163
1164 # Always start by removing any test_*_fitz.py files.
1165 for p in glob.glob(f'{pymupdf_dir_rel}/tests/test_*_fitz.py'):
1166 print(f'Removing {p=}')
1167 os.remove(p)
1168 if test_fitz:
1169 # Create copies of each test file, modified to use `pymupdf`
1170 # instead of `fitz`.
1171 for p in glob.glob(f'{pymupdf_dir_rel}/tests/test_*.py'):
1172 if os.path.basename(p).startswith('test_fitz_'):
1173 # Don't recursively generate test_fitz_fitz_foo.py,
1174 # test_fitz_fitz_fitz_foo.py, ... etc.
1175 continue
1176 branch, leaf = os.path.split(p)
1177 p2 = f'{branch}/{leaf[:5]}fitz_{leaf[5:]}'
1178 print(f'Converting {p=} to {p2=}.')
1179 with open(p, encoding='utf8') as f:
1180 text = f.read()
1181 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, '/'):
1183 assert text2 == text
1184 else:
1185 assert text2 != text, f'Unexpectedly unchanged when creating {p!r} => {p2!r}'
1186 with open(p2, 'w', encoding='utf8') as f:
1187 f.write(text2)
1188 try:
1189 log(f'Running tests with tests/run_compound.py and pytest.')
1190 run(command, env_extra=env_extra, timeout=test_timeout)
1191
1192 except subprocess.TimeoutExpired as e:
1193 log(f'Timeout when running tests.')
1194 raise
1195 finally:
1196 log(f'\n'
1197 f'[As of 2024-10-10 we get warnings from pytest/Python such as:\n'
1198 f' DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute\n'
1199 f'This seems to be due to Swig\'s handling of Py_LIMITED_API.\n'
1200 f'For details see https://github.com/swig/swig/issues/2881.\n'
1201 f']'
1202 )
1203 log('\n' + venv_info(pytest_args=f'{pytest_options} {pytest_arg}'))
1204
1205
1206 def get_pyproject_required(ppt=None):
1207 '''
1208 Returns space-separated names of required packages in pyproject.toml. We
1209 do not do a proper parse and rely on the packages being in a single line.
1210 '''
1211 if ppt is None:
1212 ppt = os.path.abspath(f'{__file__}/../../pyproject.toml')
1213 with open(ppt) as f:
1214 for line in f:
1215 m = re.match('^requires = \\[(.*)\\]$', line)
1216 if m:
1217 names = m.group(1).replace(',', ' ').replace('"', '')
1218 return names
1219 else:
1220 assert 0, f'Failed to find "requires" line in {ppt}'
1221
1222 def wrap_get_requires_for_build_wheel(dir_):
1223 '''
1224 Returns space-separated list of required
1225 packages. Looks at `dir_`/pyproject.toml and calls
1226 `dir_`/setup.py:get_requires_for_build_wheel().
1227 '''
1228 dir_abs = os.path.abspath(dir_)
1229 ret = list()
1230 ppt = os.path.join(dir_abs, 'pyproject.toml')
1231 if os.path.exists(ppt):
1232 ret += get_pyproject_required(ppt)
1233 if os.path.exists(os.path.join(dir_abs, 'setup.py')):
1234 sys.path.insert(0, dir_abs)
1235 try:
1236 from setup import get_requires_for_build_wheel as foo
1237 for i in foo():
1238 ret.append(i)
1239 finally:
1240 del sys.path[0]
1241 return ' '.join(ret)
1242
1243
1244 def venv_in(path=None):
1245 '''
1246 If path is None, returns true if we are in a venv. Otherwise returns true
1247 only if we are in venv <path>.
1248 '''
1249 if path:
1250 return os.path.abspath(sys.prefix) == os.path.abspath(path)
1251 else:
1252 return sys.prefix != sys.base_prefix
1253
1254
1255 def venv_run(args, path, recreate=True, clean=False):
1256 '''
1257 Runs command inside venv and returns termination code.
1258
1259 Args:
1260 args:
1261 List of args.
1262 path:
1263 Name of venv.
1264 recreate:
1265 If false we do not run `<sys.executable> -m venv <path>` if <path>
1266 already exists. This avoids a delay in the common case where <path>
1267 is already set up, but fails if <path> exists but does not contain
1268 a valid venv.
1269 clean:
1270 If true we first delete <path>.
1271 '''
1272 if clean:
1273 log(f'Removing any existing venv {path}.')
1274 assert path.startswith('venv-')
1275 shutil.rmtree(path, ignore_errors=1)
1276 if recreate or not os.path.isdir(path):
1277 run(f'{sys.executable} -m venv {path}')
1278 if platform.system() == 'Windows':
1279 command = f'{path}\\Scripts\\activate && python'
1280 # shlex not reliable on Windows.
1281 # Use crude quoting with "...". Seems to work.
1282 for arg in args:
1283 assert '"' not in arg
1284 command += f' "{arg}"'
1285 else:
1286 command = f'. {path}/bin/activate && python {shlex.join(args)}'
1287 e = run(command, check=0)
1288 return e
1289
1290
1291 if __name__ == '__main__':
1292 try:
1293 sys.exit(main(sys.argv))
1294 except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
1295 # Terminate relatively quietly, failed commands will usually have
1296 # generated diagnostics.
1297 log(f'{e}')
1298 sys.exit(1)
1299 # Other exceptions should not happen, and will generate a full Python
1300 # backtrace etc here.