Mercurial > hgrepos > Python2 > PyMuPDF
diff setup.py @ 1:1d09e1dec1d9 upstream
ADD: PyMuPDF v1.26.4: the original sdist.
It does not yet contain MuPDF. This normally will be downloaded when
building PyMuPDF.
| author | Franz Glasner <fzglas.hg@dom66.de> |
|---|---|
| date | Mon, 15 Sep 2025 11:37:51 +0200 |
| parents | |
| children | 5ab937c03c27 a6bc019ac0b2 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/setup.py Mon Sep 15 11:37:51 2025 +0200 @@ -0,0 +1,1493 @@ +#! /usr/bin/env python3 + +''' +Overview: + + Build script for PyMuPDF, supporting PEP-517 and simple command-line usage. + + We hard-code the URL of the MuPDF .tar.gz file that we require. This + generally points to a particular source release on mupdf.com. + + Default behaviour: + + Building an sdist: + As of 2024-002-28 we no longer download the MuPDF .tar.gz file and + embed it within the sdist. Instead it will be downloaded at build + time. + + Building PyMuPDF: + We first download the hard-coded mupdf .tar.gz file. + + Then we extract and build MuPDF locally, before building PyMuPDF + itself. So PyMuPDF will always be built with the exact MuPDF + release that we require. + + +Environmental variables: + + If building with system MuPDF (PYMUPDF_SETUP_MUPDF_BUILD is empty string): + + CFLAGS + CXXFLAGS + LDFLAGS + Added to c, c++, and link commands. + + PYMUPDF_INCLUDES + Colon-separated extra include paths. + + PYMUPDF_MUPDF_LIB + Directory containing MuPDF libraries, (libmupdf.so, + libmupdfcpp.so). + + PYMUPDF_SETUP_DEVENV + Location of devenv.com on Windows. If unset we search for it - see + wdev.py. if that fails we use just 'devenv.com'. + + PYMUPDF_SETUP_DUMMY + If 1, we build dummy sdist and wheel with no files. + + PYMUPDF_SETUP_FLAVOUR + Control building of separate wheels for PyMuPDF. + + Must be unset or a combination of 'p', 'b' and 'd'. + + Default is 'pbd'. + + 'p': + Generated wheel contains PyMuPDF code. + 'b': + Generated wheel contains MuPDF libraries; these are independent of + the Python version. + 'd': + Generated wheel contains includes and libraries for MuPDF. + + If 'p' is included, the generated wheel is called PyMuPDF. + Otherwise if 'b' is included the generated wheel is called PyMuPDFb. + Otherwise if 'd' is included the generated wheel is called PyMuPDFd. + + For example: + + 'pb': a `PyMuPDF` wheel with PyMuPDF runtime files and MuPDF + runtime shared libraries. + + 'b': a `PyMuPDFb` wheel containing MuPDF runtime shared libraries. + + 'pbd' a `PyMuPDF` wheel with PyMuPDF runtime files and MuPDF + runtime shared libraries, plus MuPDF build-time files (includes, + *.lib files on Windows). + + 'd': a `PyMuPDFd` wheel containing MuPDF build-time files + (includes, *.lib files on Windows). + + PYMUPDF_SETUP_LIBCLANG + For internal testing. + + PYMUPDF_SETUP_MUPDF_BUILD + If unset or '-', use internal hard-coded default MuPDF location. + Otherwise overrides location of MuPDF when building PyMuPDF: + Empty string: + Build PyMuPDF with the system MuPDF. + A string starting with 'git:': + Use `git clone` to get a MuPDF checkout. We use the + string in the git clone command; it must contain the git + URL from which to clone, and can also contain other `git + clone` args, for example: + PYMUPDF_SETUP_MUPDF_BUILD="git:--branch master https://github.com/ArtifexSoftware/mupdf.git" + Otherwise: + Location of mupdf directory. + + PYMUPDF_SETUP_MUPDF_BSYMBOLIC + If '0' we do not link libmupdf.so with -Bsymbolic. + + PYMUPDF_SETUP_MUPDF_TESSERACT + If '0' we build MuPDF without Tesseract. + + PYMUPDF_SETUP_MUPDF_BUILD_TYPE + Unix only. Controls build type of MuPDF. Supported values are: + debug + memento + release (default) + + PYMUPDF_SETUP_MUPDF_CLEAN + Unix only. If '1', we do a clean MuPDF build. + + PYMUPDF_SETUP_MUPDF_REFCHECK_IF + Should be preprocessor statement to enable MuPDF reference count + checking. + + As of 2024-09-27, MuPDF default is `#ifndef NDEBUG`. + + PYMUPDF_SETUP_MUPDF_TRACE_IF + Should be preprocessor statement to enable MuPDF runtime diagnostics in + response to environment variables such as MUPDF_trace. + + As of 2024-09-27, MuPDF default is `#ifndef NDEBUG`. + + PYMUPDF_SETUP_MUPDF_THIRD + If '0' and we are building on Linux with the system MuPDF + (i.e. PYMUPDF_SETUP_MUPDF_BUILD=''), then don't link with + `-lmupdf-third`. + + PYMUPDF_SETUP_MUPDF_VS_UPGRADE + If '1' we run mupdf `scripts/mupdfwrap.py` with `--vs-upgrade 1` to + help Windows builds work with Visual Studio versions newer than 2019. + + PYMUPDF_SETUP_MUPDF_TGZ + If set, overrides location of MuPDF .tar.gz file: + Empty string: + Do not download MuPDF .tar.gz file. Sdist's will not contain + MuPDF. + + A string containing '://': + The URL from which to download the MuPDF .tar.gz file. Leaf + must match mupdf-*.tar.gz. + + Otherwise: + The path of local mupdf git checkout. We put all files in this + checkout known to git into a local tar archive. + + PYMUPDF_SETUP_MUPDF_OVERWRITE_CONFIG + If '0' we do not overwrite MuPDF's include/mupdf/fitz/config.h with + PyMuPDF's own configuration file, before building MuPDF. + + PYMUPDF_SETUP_MUPDF_REBUILD + If 0 we do not (re)build mupdf. + + PYMUPDF_SETUP_PY_LIMITED_API + If not '0', we build for current Python's stable ABI. + + However if unset and we are on Python-3.13 or later, we do + not build for the stable ABI because as of 2025-03-04 SWIG + generates incorrect stable ABI code with Python-3.13 - see: + https://github.com/swig/swig/issues/3059 + + PYMUPDF_SETUP_URL_WHEEL + If set, we use an existing wheel instead of building a new wheel. + + If starts with `http://` or `https://`: + If ends with '/', we append our wheel name and download. Otherwise + we download directly. + + If starts with `file://`: + If ends with '/' we look for a matching wheel name, `using + pipcl.wheel_name_match()` to cope with differing platform tags, + for example our `manylinux2014_x86_64` will match with an existing + wheel with `manylinux2014_x86_64.manylinux_2_17_x86_64`. + + Any other prefix is an error. + + PYMUPDF_SETUP_SWIG + If set, we use this instead of `swig`. + + WDEV_VS_YEAR + If set, we use as Visual Studio year, for example '2019' or '2022'. + + WDEV_VS_GRADE + If set, we use as Visual Studio grade, for example 'Community' or + 'Professional' or 'Enterprise'. +''' + +import glob +import io +import os +import textwrap +import time +import platform +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tarfile +import traceback +import urllib.request +import zipfile + +import pipcl + + +log = pipcl.log0 + +run = pipcl.run + + +if 1: + # For debugging. + log(f'### Starting.') + pipcl.show_system() + + +PYMUPDF_SETUP_FLAVOUR = os.environ.get( 'PYMUPDF_SETUP_FLAVOUR', 'pbd') +for i in PYMUPDF_SETUP_FLAVOUR: + assert i in 'pbd', f'Unrecognised flag "{i} in {PYMUPDF_SETUP_FLAVOUR=}. Should be one of "p", "b", "d"' + +g_root = os.path.abspath( f'{__file__}/..') + +# Name of file that identifies that we are in a PyMuPDF sdist. +g_pymupdfb_sdist_marker = 'pymupdfb_sdist' + +python_version_tuple = tuple(int(x) for x in platform.python_version_tuple()[:2]) + +PYMUPDF_SETUP_PY_LIMITED_API = os.environ.get('PYMUPDF_SETUP_PY_LIMITED_API') +assert PYMUPDF_SETUP_PY_LIMITED_API in (None, '', '0', '1'), \ + f'Should be "", "0", "1" or undefined: {PYMUPDF_SETUP_PY_LIMITED_API=}.' +if PYMUPDF_SETUP_PY_LIMITED_API is None and python_version_tuple >= (3, 13): + log(f'Not defaulting to Python limited api because {platform.python_version_tuple()=}.') + g_py_limited_api = False +else: + g_py_limited_api = (PYMUPDF_SETUP_PY_LIMITED_API != '0') + +PYMUPDF_SETUP_URL_WHEEL = os.environ.get('PYMUPDF_SETUP_URL_WHEEL') +log(f'{PYMUPDF_SETUP_URL_WHEEL=}') + +PYMUPDF_SETUP_DUMMY = os.environ.get('PYMUPDF_SETUP_DUMMY') +log(f'{PYMUPDF_SETUP_DUMMY=}') + +PYMUPDF_SETUP_SWIG = os.environ.get('PYMUPDF_SETUP_SWIG') + +def _fs_remove(path): + ''' + Removes file or directory, without raising exception if it doesn't exist. + + We assert-fail if the path still exists when we return, in case of + permission problems etc. + ''' + # First try deleting `path` as a file. + try: + os.remove( path) + except Exception as e: + pass + + if os.path.exists(path): + # Try deleting `path` as a directory. Need to use + # shutil.rmtree() callback to handle permission problems; see: + # https://docs.python.org/3/library/shutil.html#rmtree-example + # + def error_fn(fn, path, excinfo): + # Clear the readonly bit and reattempt the removal. + os.chmod(path, stat.S_IWRITE) + fn(path) + shutil.rmtree( path, onerror=error_fn) + + assert not os.path.exists( path) + + +def _git_get_branch( directory): + command = f'cd {directory} && git branch --show-current' + log( f'Running: {command}') + p = subprocess.run( + command, + shell=True, + check=False, + text=True, + stdout=subprocess.PIPE, + ) + ret = None + if p.returncode == 0: + ret = p.stdout.strip() + log( f'Have found MuPDF git branch: ret={ret!r}') + return ret + + +def tar_check(path, mode='r:gz', prefix=None, remove=False): + ''' + Checks items in tar file have same <top-directory>, or <prefix> if not None. + + We fail if items in tar file have different top-level directory names. + + path: + The tar file. + mode: + As tarfile.open(). + prefix: + If not None, we fail if tar file's <top-directory> is not <prefix>. + + Returns the directory name (which will be <prefix> if not None). + ''' + with tarfile.open( path, mode) as t: + items = t.getnames() + assert items + item = items[0] + assert not item.startswith('./') and not item.startswith('../') + s = item.find('/') + if s == -1: + prefix_actual = item + '/' + else: + prefix_actual = item[:s+1] + if prefix: + assert prefix == prefix_actual, f'{path=} {prefix=} {prefix_actual=}' + for item in items[1:]: + assert item.startswith( prefix_actual), f'prefix_actual={prefix_actual!r} != item={item!r}' + return prefix_actual + + +def tar_extract(path, mode='r:gz', prefix=None, exists='raise'): + ''' + Extracts tar file into single local directory. + + We fail if items in tar file have different <top-directory>. + + path: + The tar file. + mode: + As tarfile.open(). + prefix: + If not None, we fail if tar file's <top-directory> is not <prefix>. + exists: + What to do if <top-directory> already exists: + 'raise': raise exception. + 'remove': remove existing file/directory before extracting. + 'return': return without extracting. + + Returns the directory name (which will be <prefix> if not None, with '/' + appended if not already present). + ''' + prefix_actual = tar_check( path, mode, prefix) + if os.path.exists( prefix_actual): + if exists == 'raise': + raise Exception( f'Path already exists: {prefix_actual!r}') + elif exists == 'remove': + remove( prefix_actual) + elif exists == 'return': + log( f'Not extracting {path} because already exists: {prefix_actual}') + return prefix_actual + else: + assert 0, f'Unrecognised exists={exists!r}' + assert not os.path.exists( prefix_actual), f'Path already exists: {prefix_actual}' + log( f'Extracting {path}') + with tarfile.open( path, mode) as t: + t.extractall() + return prefix_actual + + +def git_info( directory): + ''' + Returns `(sha, comment, diff, branch)`, all items are str or None if not + available. + + directory: + Root of git checkout. + ''' + sha, comment, diff, branch = '', '', '', '' + cp = subprocess.run( + f'cd {directory} && (PAGER= git show --pretty=oneline|head -n 1 && git diff)', + capture_output=1, + shell=1, + text=1, + ) + if cp.returncode == 0: + sha, _ = cp.stdout.split(' ', 1) + comment, diff = _.split('\n', 1) + cp = subprocess.run( + f'cd {directory} && git rev-parse --abbrev-ref HEAD', + capture_output=1, + shell=1, + text=1, + ) + if cp.returncode == 0: + branch = cp.stdout.strip() + log(f'git_info(): directory={directory!r} returning branch={branch!r} sha={sha!r} comment={comment!r}') + return sha, comment, diff, branch + + +def git_patch(directory, patch, hard=False): + ''' + Applies string <patch> with `git patch` in <directory>. + + If <hard> is true we clean the tree with `git checkout .` and then apply + the patch. + + Otherwise we apply patch only if it is not already applied; this might fail + if there are conflicting changes in the tree. + ''' + log(f'Applying patch in {directory}:\n{textwrap.indent(patch, " ")}') + if not patch: + return + # Carriage returns break `git apply` so we use `newline='\n'` in open(). + path = os.path.abspath(f'{directory}/pymupdf_patch.txt') + with open(path, 'w', newline='\n') as f: + f.write(patch) + log(f'Using patch file: {path}') + if hard: + run(f'cd {directory} && git checkout .') + run(f'cd {directory} && git apply {path}') + log(f'Have applied patch in {directory}.') + else: + e = run( f'cd {directory} && git apply --check --reverse {path}', check=0) + if e == 0: + log(f'Not patching {directory} because already patched.') + else: + run(f'cd {directory} && git apply {path}') + log(f'Have applied patch in {directory}.') + run(f'cd {directory} && git diff') + + +mupdf_tgz = os.path.abspath( f'{__file__}/../mupdf.tgz') + +def get_mupdf_internal(out, location=None, sha=None, local_tgz=None): + ''' + Gets MuPDF as either a .tgz or a local directory. + + Args: + out: + Either 'dir' (we return name of local directory containing mupdf) or 'tgz' (we return + name of local .tgz file containing mupdf). + location: + First, if None we set to hard-coded default URL or git location. + If starts with 'git:', should be remote git location. + Otherwise if containing '://' should be URL for .tgz. + Otherwise should path of local mupdf checkout. + sha: + If not None and we use git clone, we checkout this sha. + local_tgz: + If not None, must be local .tgz file. + Returns: + (path, location): + `path` is absolute path of local directory or .tgz containing + MuPDF, or None if we are to use system MuPDF. + + `location_out` is `location` if not None, else the hard-coded + default location. + + ''' + log(f'get_mupdf_internal(): {out=} {location=} {sha=}') + assert out in ('dir', 'tgz') + if location is None: + location = f'https://mupdf.com/downloads/archive/mupdf-{version_mupdf}-source.tar.gz' + #location = 'git:--branch master https://github.com/ArtifexSoftware/mupdf.git' + + if location == '': + # Use system mupdf. + return None, location + + local_dir = None + if local_tgz: + assert os.path.isfile(local_tgz) + elif location.startswith( 'git:'): + location_git = location[4:] + local_dir = 'mupdf-git' + + # Try to update existing checkout. + e = run(f'cd {local_dir} && git pull && git submodule update --init', check=False) + if e: + # No existing git checkout, so do a fresh clone. + _fs_remove(local_dir) + gitargs = location[4:] + run(f'git clone --recursive --depth 1 --shallow-submodules {gitargs} {local_dir}') + + # Show sha of checkout. + run( f'cd {local_dir} && git show --pretty=oneline|head -n 1', check=False) + if sha: + run( f'cd {local_dir} && git checkout {sha}') + elif '://' in location: + # Download .tgz. + local_tgz = os.path.basename( location) + suffix = '.tar.gz' + assert location.endswith(suffix), f'Unrecognised suffix in remote URL {location=}.' + name = local_tgz[:-len(suffix)] + log( f'Download {location=} {local_tgz=} {name=}') + if os.path.exists(local_tgz): + try: + tar_check(local_tgz, 'r:gz', prefix=f'{name}/') + except Exception as e: + log(f'Not using existing file {local_tgz} because invalid tar data: {e}') + _fs_remove( local_tgz) + if os.path.exists(local_tgz): + log(f'Not downloading from {location} because already present: {local_tgz!r}') + else: + log(f'Downloading from {location=} to {local_tgz=}.') + urllib.request.urlretrieve( location, local_tgz + '-') + os.rename(local_tgz + '-', local_tgz) + assert os.path.exists( local_tgz) + tar_check( local_tgz, 'r:gz', prefix=f'{name}/') + else: + assert os.path.isdir(location), f'Local MuPDF does not exist: {location=}' + local_dir = location + + assert bool(local_dir) != bool(local_tgz) + if out == 'dir': + if not local_dir: + assert local_tgz + local_dir = tar_extract( local_tgz, exists='return') + return os.path.abspath( local_dir), location + elif out == 'tgz': + if not local_tgz: + # Create .tgz containing git files in `local_dir`. + assert local_dir + if local_dir.endswith( '/'): + local_dir = local_dir[:-1] + top = os.path.basename(local_dir) + local_tgz = f'{local_dir}.tgz' + log( f'Creating .tgz from git files. {top=} {local_dir=} {local_tgz=}') + _fs_remove( local_tgz) + with tarfile.open( local_tgz, 'w:gz') as f: + for name in pipcl.git_items( local_dir, submodules=True): + path = os.path.join( local_dir, name) + if os.path.isfile( path): + path2 = f'{top}/{name}' + log(f'Adding {path=} {path2=}.') + f.add( path, path2, recursive=False) + return os.path.abspath( local_tgz), location + else: + assert 0, f'Unrecognised {out=}' + + + +def get_mupdf_tgz(): + ''' + Creates .tgz file called containing MuPDF source, for inclusion in an + sdist. + + What we do depends on environmental variable PYMUPDF_SETUP_MUPDF_TGZ; see + docs at start of this file for details. + + Returns name of top-level directory within the .tgz file. + ''' + name, location = get_mupdf_internal( 'tgz', os.environ.get('PYMUPDF_SETUP_MUPDF_TGZ')) + return name, location + + +def get_mupdf(path=None, sha=None): + ''' + Downloads and/or extracts mupdf and returns (path, location) where `path` + is the local mupdf directory and `location` is where it came from. + + Exact behaviour depends on environmental variable + PYMUPDF_SETUP_MUPDF_BUILD; see docs at start of this file for details. + ''' + m = os.environ.get('PYMUPDF_SETUP_MUPDF_BUILD') + if m == '-': + # This allows easy specification in Github actions. + m = None + if m is None and os.path.isfile(mupdf_tgz): + # This makes us use tgz inside sdist. + log(f'Using local tgz: {mupdf_tgz=}') + return get_mupdf_internal('dir', local_tgz=mupdf_tgz) + return get_mupdf_internal('dir', m) + + +linux = sys.platform.startswith( 'linux') or 'gnu' in sys.platform +openbsd = sys.platform.startswith( 'openbsd') +freebsd = sys.platform.startswith( 'freebsd') +darwin = sys.platform.startswith( 'darwin') +windows = platform.system() == 'Windows' or platform.system().startswith('CYGWIN') +msys2 = platform.system().startswith('MSYS_NT-') + +pyodide_flags = '-fwasm-exceptions' + +if os.environ.get('PYODIDE') == '1': + if os.environ.get('OS') != 'pyodide': + log('PYODIDE=1, setting OS=pyodide.') + os.environ['OS'] = 'pyodide' + os.environ['XCFLAGS'] = pyodide_flags + os.environ['XCXXFLAGS'] = pyodide_flags + +pyodide = os.environ.get('OS') == 'pyodide' + +def build(): + ''' + pipcl.py `build_fn()` callback. + ''' + #pipcl.show_sysconfig() + + if PYMUPDF_SETUP_DUMMY == '1': + log(f'{PYMUPDF_SETUP_DUMMY=} Building dummy wheel with no files.') + return list() + + # Download MuPDF. + # + mupdf_local, mupdf_location = get_mupdf() + if mupdf_local: + mupdf_version_tuple = get_mupdf_version(mupdf_local) + # else we cannot determine version this way and do not use it + + build_type = os.environ.get( 'PYMUPDF_SETUP_MUPDF_BUILD_TYPE', 'release') + assert build_type in ('debug', 'memento', 'release'), \ + f'Unrecognised build_type={build_type!r}' + + overwrite_config = os.environ.get('PYMUPDF_SETUP_MUPDF_OVERWRITE_CONFIG', '1') == '1' + + PYMUPDF_SETUP_MUPDF_REFCHECK_IF = os.environ.get('PYMUPDF_SETUP_MUPDF_REFCHECK_IF') + PYMUPDF_SETUP_MUPDF_TRACE_IF = os.environ.get('PYMUPDF_SETUP_MUPDF_TRACE_IF') + + # Build MuPDF shared libraries. + # + if windows: + mupdf_build_dir = build_mupdf_windows( + mupdf_local, + build_type, + overwrite_config, + g_py_limited_api, + PYMUPDF_SETUP_MUPDF_REFCHECK_IF, + PYMUPDF_SETUP_MUPDF_TRACE_IF, + ) + else: + if 'p' not in PYMUPDF_SETUP_FLAVOUR and 'b' not in PYMUPDF_SETUP_FLAVOUR: + # We only need MuPDF headers, so no point building MuPDF. + log(f'Not building MuPDF because not Windows and {PYMUPDF_SETUP_FLAVOUR=}.') + mupdf_build_dir = None + else: + mupdf_build_dir = build_mupdf_unix( + mupdf_local, + build_type, + overwrite_config, + g_py_limited_api, + PYMUPDF_SETUP_MUPDF_REFCHECK_IF, + PYMUPDF_SETUP_MUPDF_TRACE_IF, + PYMUPDF_SETUP_SWIG, + ) + log( f'build(): mupdf_build_dir={mupdf_build_dir!r}') + + # Build rebased `extra` module. + # + if 'p' in PYMUPDF_SETUP_FLAVOUR: + path_so_leaf = _build_extension( + mupdf_local, + mupdf_build_dir, + build_type, + g_py_limited_api, + ) + else: + log(f'Not building extension.') + path_so_leaf = None + + # Generate list of (from, to) items to return to pipcl. What we add depends + # on PYMUPDF_SETUP_FLAVOUR. + # + ret = list() + def add(flavour, from_, to_): + assert flavour in 'pbd' + if flavour in PYMUPDF_SETUP_FLAVOUR: + ret.append((from_, to_)) + + to_dir = 'pymupdf/' + to_dir_d = f'{to_dir}/mupdf-devel' + + # Add implementation files. + add('p', f'{g_root}/src/__init__.py', to_dir) + add('p', f'{g_root}/src/__main__.py', to_dir) + add('p', f'{g_root}/src/pymupdf.py', to_dir) + add('p', f'{g_root}/src/table.py', to_dir) + add('p', f'{g_root}/src/utils.py', to_dir) + add('p', f'{g_root}/src/_wxcolors.py', to_dir) + add('p', f'{g_root}/src/_apply_pages.py', to_dir) + add('p', f'{g_root}/src/build/extra.py', to_dir) + if path_so_leaf: + add('p', f'{g_root}/src/build/{path_so_leaf}', to_dir) + + # Add support for `fitz` backwards compatibility. + add('p', f'{g_root}/src/fitz___init__.py', 'fitz/__init__.py') + add('p', f'{g_root}/src/fitz_table.py', 'fitz/table.py') + add('p', f'{g_root}/src/fitz_utils.py', 'fitz/utils.py') + + if mupdf_local: + # Add MuPDF Python API. + add('p', f'{mupdf_build_dir}/mupdf.py', to_dir) + + # Add MuPDF shared libraries. + if windows: + wp = pipcl.wdev.WindowsPython() + add('p', f'{mupdf_build_dir}/_mupdf.pyd', to_dir) + add('b', f'{mupdf_build_dir}/mupdfcpp{wp.cpu.windows_suffix}.dll', to_dir) + + # Add Windows .lib files. + mupdf_build_dir2 = _windows_lib_directory(mupdf_local, build_type) + add('d', f'{mupdf_build_dir2}/mupdfcpp{wp.cpu.windows_suffix}.lib', f'{to_dir_d}/lib/') + if mupdf_version_tuple >= (1, 26): + # MuPDF-1.25+ language bindings build also builds libmuthreads. + add('d', f'{mupdf_build_dir2}/libmuthreads.lib', f'{to_dir_d}/lib/') + elif darwin: + add('p', f'{mupdf_build_dir}/_mupdf.so', to_dir) + add('b', f'{mupdf_build_dir}/libmupdfcpp.so', to_dir) + add('b', f'{mupdf_build_dir}/libmupdf.dylib', to_dir) + add('d', f'{mupdf_build_dir}/libmupdf-threads.a', f'{to_dir_d}/lib/') + elif pyodide: + add('p', f'{mupdf_build_dir}/_mupdf.so', to_dir) + add('b', f'{mupdf_build_dir}/libmupdfcpp.so', 'PyMuPDF.libs/') + add('b', f'{mupdf_build_dir}/libmupdf.so', 'PyMuPDF.libs/') + else: + add('p', f'{mupdf_build_dir}/_mupdf.so', to_dir) + add('b', pipcl.get_soname(f'{mupdf_build_dir}/libmupdfcpp.so'), to_dir) + add('b', pipcl.get_soname(f'{mupdf_build_dir}/libmupdf.so'), to_dir) + add('d', f'{mupdf_build_dir}/libmupdf-threads.a', f'{to_dir_d}/lib/') + + if 'd' in PYMUPDF_SETUP_FLAVOUR: + # Add MuPDF C and C++ headers to `ret_d`. Would prefer to use + # pipcl.git_items() but hard-coded mupdf tree is not a git + # checkout. + # + for root in ( + f'{mupdf_local}/include', + f'{mupdf_local}/platform/c++/include', + ): + for dirpath, dirnames, filenames in os.walk(root): + for filename in filenames: + if not filename.endswith('.h'): + continue + header_abs = os.path.join(dirpath, filename) + assert header_abs.startswith(root) + header_rel = header_abs[len(root)+1:] + add('d', f'{header_abs}', f'{to_dir_d}/include/{header_rel}') + + # Add a .py file containing location of MuPDF. + try: + sha, comment, diff, branch = git_info(g_root) + except Exception as e: + log(f'Failed to get git information: {e}') + sha, comment, diff, branch = (None, None, None, None) + swig = PYMUPDF_SETUP_SWIG or 'swig' + swig_version_text = run(f'{swig} --version', capture=1) + m = re.search('\nSWIG Version ([^\n]+)', swig_version_text) + log(f'{swig_version_text=}') + assert m, f'Unrecognised {swig_version_text=}' + swig_version = m.group(1) + def int_or_0(text): + try: + return int(text) + except Exception: + return 0 + swig_version_tuple = tuple(int_or_0(i) for i in swig_version.split('.')) + log(f'{swig_version=}') + text = '' + text += f'mupdf_location = {mupdf_location!r}\n' + text += f'pymupdf_version = {version_p!r}\n' + text += f'pymupdf_git_sha = {sha!r}\n' + text += f'pymupdf_git_diff = {diff!r}\n' + text += f'pymupdf_git_branch = {branch!r}\n' + text += f'swig_version = {swig_version!r}\n' + text += f'swig_version_tuple = {swig_version_tuple!r}\n' + add('p', text.encode(), f'{to_dir}/_build.py') + + # Add single README file. + if 'p' in PYMUPDF_SETUP_FLAVOUR: + add('p', f'{g_root}/README.md', '$dist-info/README.md') + elif 'b' in PYMUPDF_SETUP_FLAVOUR: + add('b', f'{g_root}/READMEb.md', '$dist-info/README.md') + elif 'd' in PYMUPDF_SETUP_FLAVOUR: + add('d', f'{g_root}/READMEd.md', '$dist-info/README.md') + + return ret + + +def env_add(env, name, value, sep=' ', prepend=False, verbose=False): + ''' + Appends/prepends `<value>` to `env[name]`. + + If `name` is not in `env`, we use os.environ[name] if it exists. + ''' + v = env.get(name) + if verbose: + log(f'Initally: {name}={v!r}') + if v is None: + v = os.environ.get(name) + if v is None: + env[ name] = value + else: + if prepend: + env[ name] = f'{value}{sep}{v}' + else: + env[ name] = f'{v}{sep}{value}' + if verbose: + log(f'Returning with {name}={env[name]!r}') + + +def build_mupdf_windows( + mupdf_local, + build_type, + overwrite_config, + g_py_limited_api, + PYMUPDF_SETUP_MUPDF_REFCHECK_IF, + PYMUPDF_SETUP_MUPDF_TRACE_IF, + ): + + assert mupdf_local + + if overwrite_config: + mupdf_config_h = f'{mupdf_local}/include/mupdf/fitz/config.h' + prefix = '#define TOFU_CJK_EXT 1 /* PyMuPDF override. */\n' + with open(mupdf_config_h) as f: + text = f.read() + if text.startswith(prefix): + print(f'Not modifying {mupdf_config_h} because already has prefix {prefix!r}.') + else: + print(f'Prefixing {mupdf_config_h} with {prefix!r}.') + text = prefix + text + st = os.stat(mupdf_config_h) + with open(mupdf_config_h, 'w') as f: + f.write(text) + os.utime(mupdf_config_h, (st.st_atime, st.st_mtime)) + + wp = pipcl.wdev.WindowsPython() + tesseract = '' if os.environ.get('PYMUPDF_SETUP_MUPDF_TESSERACT') == '0' else 'tesseract-' + windows_build_tail = f'build\\shared-{tesseract}{build_type}' + if g_py_limited_api: + windows_build_tail += f'-Py_LIMITED_API_{pipcl.current_py_limited_api()}' + windows_build_tail += f'-x{wp.cpu.bits}-py{wp.version}' + windows_build_dir = f'{mupdf_local}\\{windows_build_tail}' + #log( f'Building mupdf.') + devenv = os.environ.get('PYMUPDF_SETUP_DEVENV') + if not devenv: + try: + # Prefer VS-2022 as that is what Github provide in windows-2022. + log(f'Looking for Visual Studio 2022.') + vs = pipcl.wdev.WindowsVS(year=2022) + except Exception as e: + log(f'Failed to find VS-2022:\n' + f'{textwrap.indent(traceback.format_exc(), " ")}' + ) + log(f'Looking for any Visual Studio.') + vs = pipcl.wdev.WindowsVS() + log(f'vs:\n{vs.description_ml(" ")}') + devenv = vs.devenv + if not devenv: + devenv = 'devenv.com' + log( f'Cannot find devenv.com in default locations, using: {devenv!r}') + command = f'cd "{mupdf_local}" && "{sys.executable}" ./scripts/mupdfwrap.py' + if os.environ.get('PYMUPDF_SETUP_MUPDF_VS_UPGRADE') == '1': + command += ' --vs-upgrade 1' + + # Would like to simply do f'... --devenv {shutil.quote(devenv)}', but + # it looks like if `devenv` has spaces then `shutil.quote()` puts it + # inside single quotes, which then appear to be ignored when run by + # subprocess.run(). + # + # So instead we strip any enclosing quotes and the enclose with + # double-quotes. + # + if len(devenv) >= 2: + for q in '"', "'": + if devenv.startswith( q) and devenv.endswith( q): + devenv = devenv[1:-1] + command += f' -d {windows_build_tail}' + command += f' -b' + if PYMUPDF_SETUP_MUPDF_REFCHECK_IF: + command += f' --refcheck-if "{PYMUPDF_SETUP_MUPDF_REFCHECK_IF}"' + if PYMUPDF_SETUP_MUPDF_TRACE_IF: + command += f' --trace-if "{PYMUPDF_SETUP_MUPDF_TRACE_IF}"' + command += f' --devenv "{devenv}"' + command += f' all' + if os.environ.get( 'PYMUPDF_SETUP_MUPDF_REBUILD') == '0': + log( f'PYMUPDF_SETUP_MUPDF_REBUILD is "0" so not building MuPDF; would have run: {command}') + else: + log( f'Building MuPDF by running: {command}') + subprocess.run( command, shell=True, check=True) + log( f'Finished building mupdf.') + + return windows_build_dir + + +def _windows_lib_directory(mupdf_local, build_type): + ret = f'{mupdf_local}/platform/win32/' + if _cpu_bits() == 64: + ret += 'x64/' + if build_type == 'release': + ret += 'Release/' + elif build_type == 'debug': + ret += 'Debug/' + else: + assert 0, f'Unrecognised {build_type=}.' + return ret + + +def _cpu_bits(): + if sys.maxsize == 2**31 - 1: + return 32 + return 64 + + +def build_mupdf_unix( + mupdf_local, + build_type, + overwrite_config, + g_py_limited_api, + PYMUPDF_SETUP_MUPDF_REFCHECK_IF, + PYMUPDF_SETUP_MUPDF_TRACE_IF, + PYMUPDF_SETUP_SWIG, + ): + ''' + Builds MuPDF. + + Args: + mupdf_local: + Path of MuPDF directory or None if we are using system MuPDF. + + Returns the absolute path of build directory within MuPDF, e.g. + `.../mupdf/build/pymupdf-shared-release`, or `None` if we are using the + system MuPDF. + ''' + if not mupdf_local: + log( f'Using system mupdf.') + return None + + env = dict() + if overwrite_config: + # By predefining TOFU_CJK_EXT here, we don't need to modify + # MuPDF's include/mupdf/fitz/config.h. + log( f'Setting XCFLAGS and XCXXFLAGS to predefine TOFU_CJK_EXT.') + env_add(env, 'XCFLAGS', '-DTOFU_CJK_EXT') + env_add(env, 'XCXXFLAGS', '-DTOFU_CJK_EXT') + + if openbsd or freebsd: + env_add(env, 'CXX', 'c++', ' ') + + if darwin and os.environ.get('GITHUB_ACTIONS') == 'true': + if os.environ.get('ImageOS') == 'macos13': + # On Github macos13 we need to use Clang/LLVM (Homebrew) 15.0.7, + # otherwise mupdf:thirdparty/tesseract/src/api/baseapi.cpp fails to + # compile with: + # + # thirdparty/tesseract/src/api/baseapi.cpp:150:25: error: 'recursive_directory_iterator' is unavailable: introduced in macOS 10.15 + # + # See: + # https://github.com/actions/runner-images/blob/main/images/macos/macos-13-Readme.md + # + log(f'Using llvm@15 clang and clang++') + cl15 = pipcl.run(f'brew --prefix llvm@15', capture=1) + log(f'{cl15=}') + cl15 = cl15.strip() + pipcl.run(f'ls -lL {cl15}') + pipcl.run(f'ls -lL {cl15}/bin') + cc = f'{cl15}/bin/clang' + cxx = f'{cl15}/bin/clang++' + env['CC'] = cc + env['CXX'] = cxx + + # Show compiler versions. + cc = env.get('CC', 'cc') + cxx = env.get('CXX', 'c++') + pipcl.run(f'{cc} --version') + pipcl.run(f'{cxx} --version') + + # Add extra flags for MacOS cross-compilation, where ARCHFLAGS can be + # '-arch arm64'. + # + archflags = os.environ.get( 'ARCHFLAGS') + if archflags: + env_add(env, 'XCFLAGS', archflags) + env_add(env, 'XLIBS', archflags) + + mupdf_version_tuple = get_mupdf_version(mupdf_local) + + # We specify a build directory path containing 'pymupdf' so that we + # coexist with non-PyMuPDF builds (because PyMuPDF builds have a + # different config.h). + # + # We also append further text to try to allow different builds to + # work if they reuse the mupdf directory. + # + # Using platform.machine() (e.g. 'amd64') ensures that different + # builds of mupdf on a shared filesystem can coexist. Using + # $_PYTHON_HOST_PLATFORM allows cross-compiled cibuildwheel builds + # to coexist, e.g. on github. + # + # Have experimented with looking at getconf_ARG_MAX to decide whether to + # omit `PyMuPDF-` from the build directory, to avoid command-too-long + # errors with mupdf-1.26. But it seems that `getconf ARG_MAX` returns + # a system limit, not the actual limit of the current shell, and there + # doesn't seem to be a way to find the current shell's limit. + # + build_prefix = f'PyMuPDF-' + if mupdf_version_tuple >= (1, 26): + # Avoid link command length problems seen on musllinux. + build_prefix = '' + if pyodide: + build_prefix += 'pyodide-' + else: + build_prefix += f'{platform.machine()}-' + build_prefix_extra = os.environ.get( '_PYTHON_HOST_PLATFORM') + if build_prefix_extra: + build_prefix += f'{build_prefix_extra}-' + build_prefix += 'shared-' + if msys2: + # Error in mupdf/scripts/tesseract/endianness.h: + # #error "I don't know what architecture this is!" + log(f'msys2: building MuPDF without tesseract.') + elif os.environ.get('PYMUPDF_SETUP_MUPDF_TESSERACT') == '0': + log(f'PYMUPDF_SETUP_MUPDF_TESSERACT=0 so building mupdf without tesseract.') + else: + build_prefix += 'tesseract-' + if ( + linux + and os.environ.get('PYMUPDF_SETUP_MUPDF_BSYMBOLIC', '1') == '1' + ): + log(f'Appending `bsymbolic-` to MuPDF build path.') + build_prefix += 'bsymbolic-' + log(f'{g_py_limited_api=}') + if g_py_limited_api: + build_prefix += f'Py_LIMITED_API_{pipcl.current_py_limited_api()}-' + unix_build_dir = f'{mupdf_local}/build/{build_prefix}{build_type}' + PYMUPDF_SETUP_MUPDF_CLEAN = os.environ.get('PYMUPDF_SETUP_MUPDF_CLEAN') + if PYMUPDF_SETUP_MUPDF_CLEAN == '1': + log(f'{PYMUPDF_SETUP_MUPDF_CLEAN=}, deleting {unix_build_dir=}.') + shutil.rmtree(unix_build_dir, ignore_errors=1) + # We need MuPDF's Python bindings, so we build MuPDF with + # `mupdf/scripts/mupdfwrap.py` instead of running `make`. + # + command = f'cd {mupdf_local} &&' + for n, v in env.items(): + command += f' {n}={shlex.quote(v)}' + command += f' {sys.executable} ./scripts/mupdfwrap.py' + if PYMUPDF_SETUP_SWIG: + command += f' --swig {shlex.quote(PYMUPDF_SETUP_SWIG)}' + command += f' -d build/{build_prefix}{build_type} -b' + #command += f' --m-target libs' + if PYMUPDF_SETUP_MUPDF_REFCHECK_IF: + command += f' --refcheck-if "{PYMUPDF_SETUP_MUPDF_REFCHECK_IF}"' + if PYMUPDF_SETUP_MUPDF_TRACE_IF: + command += f' --trace-if "{PYMUPDF_SETUP_MUPDF_TRACE_IF}"' + if 'p' in PYMUPDF_SETUP_FLAVOUR: + command += ' all' + else: + command += ' m01' # No need for C++/Python bindings. + command += f' && echo {unix_build_dir}:' + command += f' && ls -l {unix_build_dir}' + + if os.environ.get( 'PYMUPDF_SETUP_MUPDF_REBUILD') == '0': + log( f'PYMUPDF_SETUP_MUPDF_REBUILD is "0" so not building MuPDF; would have run: {command}') + else: + log( f'Building MuPDF by running: {command}') + subprocess.run( command, shell=True, check=True) + log( f'Finished building mupdf.') + + return unix_build_dir + + +def get_mupdf_version(mupdf_dir): + path = f'{mupdf_dir}/include/mupdf/fitz/version.h' + with open(path) as f: + text = f.read() + v0 = re.search('#define FZ_VERSION_MAJOR ([0-9]+)', text) + v1 = re.search('#define FZ_VERSION_MINOR ([0-9]+)', text) + v2 = re.search('#define FZ_VERSION_PATCH ([0-9]+)', text) + assert v0 and v1 and v2, f'Cannot find MuPDF version numbers in {path=}.' + v0 = int(v0.group(1)) + v1 = int(v1.group(1)) + v2 = int(v2.group(1)) + return v0, v1, v2 + +def _fs_update(text, path): + try: + with open( path) as f: + text0 = f.read() + except OSError: + text0 = None + print(f'path={path!r} text==text0={text==text0!r}') + if text != text0: + with open( path, 'w') as f: + f.write( text) + + +def _build_extension( mupdf_local, mupdf_build_dir, build_type, g_py_limited_api): + ''' + Builds Python extension module `_extra`. + + Returns leafname of the generated shared libraries within mupdf_build_dir. + ''' + (compiler_extra, linker_extra, includes, defines, optimise, debug, libpaths, libs, libraries) \ + = _extension_flags( mupdf_local, mupdf_build_dir, build_type) + log(f'_build_extension(): {g_py_limited_api=} {defines=}') + if mupdf_local: + includes = ( + f'{mupdf_local}/platform/c++/include', + f'{mupdf_local}/include', + ) + + # Build rebased extension module. + log('Building PyMuPDF rebased.') + compile_extra_cpp = '' + if darwin: + # Avoids `error: cannot pass object of non-POD type + # 'std::nullptr_t' through variadic function; call will abort at + # runtime` when compiling `mupdf::pdf_dict_getl(..., nullptr)`. + compile_extra_cpp += ' -Wno-non-pod-varargs' + # Avoid errors caused by mupdf's C++ bindings' exception classes + # not having `nothrow` to match the base exception class. + compile_extra_cpp += ' -std=c++14' + if windows: + wp = pipcl.wdev.WindowsPython() + libs = f'mupdfcpp{wp.cpu.windows_suffix}.lib' + else: + libs = ('mupdf', 'mupdfcpp') + libraries = [ + f'{mupdf_build_dir}/libmupdf.so' + f'{mupdf_build_dir}/libmupdfcpp.so' + ] + + path_so_leaf = pipcl.build_extension( + name = 'extra', + path_i = f'{g_root}/src/extra.i', + outdir = f'{g_root}/src/build', + includes = includes, + defines = defines, + libpaths = libpaths, + libs = libs, + compiler_extra = compiler_extra + compile_extra_cpp, + linker_extra = linker_extra, + optimise = optimise, + debug = debug, + prerequisites_swig = None, + prerequisites_compile = f'{mupdf_local}/include', + prerequisites_link = libraries, + py_limited_api = g_py_limited_api, + swig = PYMUPDF_SETUP_SWIG, + ) + + return path_so_leaf + + +def _extension_flags( mupdf_local, mupdf_build_dir, build_type): + ''' + Returns various flags to pass to pipcl.build_extension(). + ''' + compiler_extra = '' + linker_extra = '' + if build_type == 'memento': + compiler_extra += ' -DMEMENTO' + if mupdf_build_dir: + mupdf_build_dir_flags = os.path.basename( mupdf_build_dir).split( '-') + else: + mupdf_build_dir_flags = [build_type] + optimise = 'release' in mupdf_build_dir_flags + debug = 'debug' in mupdf_build_dir_flags + r_extra = '' + defines = list() + if windows: + defines.append('FZ_DLL_CLIENT') + wp = pipcl.wdev.WindowsPython() + if os.environ.get('PYMUPDF_SETUP_MUPDF_VS_UPGRADE') == '1': + # MuPDF C++ build uses a parallel build tree with updated VS files. + infix = 'win32-vs-upgrade' + else: + infix = 'win32' + build_type_infix = 'Debug' if debug else 'Release' + libpaths = ( + f'{mupdf_local}\\platform\\{infix}\\{wp.cpu.windows_subdir}{build_type_infix}', + f'{mupdf_local}\\platform\\{infix}\\{wp.cpu.windows_subdir}{build_type_infix}Tesseract', + ) + libs = f'mupdfcpp{wp.cpu.windows_suffix}.lib' + libraries = f'{mupdf_local}\\platform\\{infix}\\{wp.cpu.windows_subdir}{build_type_infix}\\{libs}' + compiler_extra = '' + else: + libs = ['mupdf'] + compiler_extra += ( + ' -Wall' + ' -Wno-deprecated-declarations' + ' -Wno-unused-const-variable' + ) + if mupdf_local: + libpaths = (mupdf_build_dir,) + libraries = f'{mupdf_build_dir}/{libs[0]}' + if openbsd: + compiler_extra += ' -Wno-deprecated-declarations' + else: + libpaths = os.environ.get('PYMUPDF_MUPDF_LIB') + libraries = None + if libpaths: + libpaths = libpaths.split(':') + + if mupdf_local: + includes = ( + f'{mupdf_local}/include', + f'{mupdf_local}/include/mupdf', + f'{mupdf_local}/thirdparty/freetype/include', + ) + else: + # Use system MuPDF. + includes = list() + pi = os.environ.get('PYMUPDF_INCLUDES') + if pi: + includes += pi.split(':') + pmi = os.environ.get('PYMUPDF_MUPDF_INCLUDE') + if pmi: + includes.append(pmi) + ldflags = os.environ.get('LDFLAGS') + if ldflags: + linker_extra += f' {ldflags}' + cflags = os.environ.get('CFLAGS') + if cflags: + compiler_extra += f' {cflags}' + cxxflags = os.environ.get('CXXFLAGS') + if cxxflags: + compiler_extra += f' {cxxflags}' + + if pyodide: + compiler_extra += f' {pyodide_flags}' + linker_extra += f' {pyodide_flags}' + + return compiler_extra, linker_extra, includes, defines, optimise, debug, libpaths, libs, libraries, + + +def sdist(): + ret = list() + if PYMUPDF_SETUP_DUMMY == '1': + return ret + + if PYMUPDF_SETUP_FLAVOUR == 'b': + # Create a minimal sdist that will build/install a dummy PyMuPDFb. + for p in ( + 'setup.py', + 'pipcl.py', + 'wdev.py', + 'pyproject.toml', + ): + ret.append(p) + ret.append( + ( + b'This file indicates that we are a PyMuPDFb sdist and should build/install a dummy PyMuPDFb package.\n', + g_pymupdfb_sdist_marker, + ) + ) + return ret + + for p in pipcl.git_items( g_root): + if p.startswith( + ( + 'docs/', + 'signatures/', + '.', + ) + ): + pass + else: + ret.append(p) + if 0: + tgz, mupdf_location = get_mupdf_tgz() + if tgz: + ret.append((tgz, mupdf_tgz)) + else: + log(f'Not including MuPDF .tgz in sdist.') + return ret + + +classifier = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'Operating System :: MacOS', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: C', + 'Programming Language :: C++', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: Implementation :: CPython', + 'Topic :: Utilities', + 'Topic :: Multimedia :: Graphics', + 'Topic :: Software Development :: Libraries', + ] + +# We generate different wheels depending on PYMUPDF_SETUP_FLAVOUR. +# + +# PyMuPDF version. +version_p = '1.26.4' + +version_mupdf = '1.26.7' + +# PyMuPDFb version. This is the PyMuPDF version whose PyMuPDFb wheels we will +# (re)use if generating separate PyMuPDFb wheels. Though as of PyMuPDF-1.24.11 +# (2024-10-03) we no longer use PyMuPDFb wheels so this is actually unused. +# +version_b = '1.26.3' + +if os.path.exists(f'{g_root}/{g_pymupdfb_sdist_marker}'): + + # We are in a PyMuPDFb sdist. We specify a dummy package so that pip builds + # from sdists work - pip's build using PyMuPDF's sdist will already create + # the required binaries, but pip will still see `requires_dist` set to + # 'PyMuPDFb', so will also download and build PyMuPDFb's sdist. + # + log(f'Specifying dummy PyMuPDFb wheel.') + + def get_requires_for_build_wheel(config_settings=None): + return list() + + p = pipcl.Package( + 'PyMuPDFb', + version_b, + summary = 'Dummy PyMuPDFb wheel', + description = '', + author = 'Artifex', + author_email = 'support@artifex.com', + license = 'GNU AFFERO GPL 3.0', + tag_python = 'py3', + ) + +else: + # A normal PyMuPDF package. + + with open( f'{g_root}/README.md', encoding='utf-8') as f: + readme_p = f.read() + + with open( f'{g_root}/READMEb.md', encoding='utf-8') as f: + readme_b = f.read() + + with open( f'{g_root}/READMEd.md', encoding='utf-8') as f: + readme_d = f.read() + + tag_python = None + requires_dist = list() + entry_points = None + + if 'p' in PYMUPDF_SETUP_FLAVOUR: + version = version_p + name = 'PyMuPDF' + readme = readme_p + summary = 'A high performance Python library for data extraction, analysis, conversion & manipulation of PDF (and other) documents.' + if 'b' not in PYMUPDF_SETUP_FLAVOUR: + requires_dist.append(f'PyMuPDFb =={version_b}') + # Create a `pymupdf` command. + entry_points = textwrap.dedent(''' + [console_scripts] + pymupdf = pymupdf.__main__:main + ''') + elif 'b' in PYMUPDF_SETUP_FLAVOUR: + version = version_b + name = 'PyMuPDFb' + readme = readme_b + summary = 'MuPDF shared libraries for PyMuPDF.' + tag_python = 'py3' + elif 'd' in PYMUPDF_SETUP_FLAVOUR: + version = version_b + name = 'PyMuPDFd' + readme = readme_d + summary = 'MuPDF build-time files for PyMuPDF.' + tag_python = 'py3' + else: + assert 0, f'Unrecognised {PYMUPDF_SETUP_FLAVOUR=}.' + + if os.environ.get('PYODIDE_ROOT'): + # We can't pip install pytest on pyodide, so specify it here. + requires_dist.append('pytest') + + p = pipcl.Package( + name, + version, + summary = summary, + description = readme, + description_content_type = 'text/markdown', + classifier = classifier, + author = 'Artifex', + author_email = 'support@artifex.com', + requires_dist = requires_dist, + requires_python = '>=3.9', + license = 'Dual Licensed - GNU AFFERO GPL 3.0 or Artifex Commercial License', + project_url = [ + ('Documentation, https://pymupdf.readthedocs.io/'), + ('Source, https://github.com/pymupdf/pymupdf'), + ('Tracker, https://github.com/pymupdf/PyMuPDF/issues'), + ('Changelog, https://pymupdf.readthedocs.io/en/latest/changes.html'), + ], + + entry_points = entry_points, + + fn_build=build, + fn_sdist=sdist, + + tag_python=tag_python, + py_limited_api=g_py_limited_api, + + # 30MB: 9 ZIP_DEFLATED + # 28MB: 9 ZIP_BZIP2 + # 23MB: 9 ZIP_LZMA + #wheel_compression = zipfile.ZIP_DEFLATED if (darwin or pyodide) else zipfile.ZIP_LZMA, + wheel_compresslevel = 9, + ) + + def get_requires_for_build_wheel(config_settings=None): + ''' + Adds to pyproject.toml:[build-system]:requires, allowing programmatic + control over what packages we require. + ''' + def platform_release_tuple(): + r = platform.release() + r = r.split('.') + r = tuple(int(i) for i in r) + log(f'platform_release_tuple() returning {r=}.') + return r + + ret = list() + libclang = os.environ.get('PYMUPDF_SETUP_LIBCLANG') + if libclang: + print(f'Overriding to use {libclang=}.') + ret.append(libclang) + elif openbsd: + print(f'OpenBSD: libclang not available via pip; assuming `pkg_add py3-llvm`.') + elif darwin and platform.machine() == 'arm64': + print(f'MacOS/arm64: forcing use of libclang 16.0.6 because 18.1.1 known to fail with `clang.cindex.TranslationUnitLoadError: Error parsing translation unit.`') + ret.append('libclang==16.0.6') + elif darwin and platform_release_tuple() < (18,): + # There are still of problems when building on old macos. + ret.append('libclang==14.0.6') + else: + ret.append('libclang') + if msys2: + print(f'msys2: pip install of swig does not build; assuming `pacman -S swig`.') + elif openbsd: + print(f'OpenBSD: pip install of swig does not build; assuming `pkg_add swig`.') + else: + ret.append( 'swig') + return ret + + +if PYMUPDF_SETUP_URL_WHEEL: + def build_wheel( + wheel_directory, + config_settings=None, + metadata_directory=None, + p=p, + ): + ''' + Instead of building wheel, we look for and copy a wheel from location + specified by PYMUPDF_SETUP_URL_WHEEL. + ''' + log(f'{PYMUPDF_SETUP_URL_WHEEL=}') + log(f'{p.wheel_name()=}') + url = PYMUPDF_SETUP_URL_WHEEL + if url.startswith(('http://', 'https://')): + leaf = p.wheel_name() + out_path = f'{wheel_directory}{leaf}' + out_path_temp = out_path + '-' + if url.endswith('/'): + url += leaf + log(f'Downloading from {url=} to {out_path_temp=}.') + urllib.request.urlretrieve(url, out_path_temp) + elif url.startswith(f'file://'): + in_path = url[len('file://'):] + log(f'{in_path=}') + if in_path.endswith('/'): + # Look for matching wheel within this directory. + wheels = glob.glob(f'{in_path}*.whl') + log(f'{len(wheels)=}') + for in_path in wheels: + log(f'{in_path=}') + leaf = os.path.basename(in_path) + if p.wheel_name_match(leaf): + log(f'Match: {in_path=}') + break + else: + message = f'Cannot find matching for {p.wheel_name()=} in ({len(wheels)=}):\n' + wheels_text = '' + for wheel in wheels: + wheels_text += f' {wheel}\n' + assert 0, f'Cannot find matching for {p.wheel_name()=} in:\n{wheels_text}' + else: + leaf = os.path.basename(in_path) + out_path = os.path.join(wheel_directory, leaf) + out_path_temp = out_path + '-' + log(f'Copying from {in_path=} to {out_path_temp=}.') + shutil.copy2(in_path, out_path_temp) + else: + assert 0, f'Unrecognised prefix in {PYMUPDF_SETUP_URL_WHEEL=}.' + + log(f'Renaming from:\n {out_path_temp}\nto:\n {out_path}.') + os.rename(out_path_temp, out_path) + return os.path.basename(out_path) +else: + build_wheel = p.build_wheel + +build_sdist = p.build_sdist + + +if __name__ == '__main__': + p.handle_argv(sys.argv)
