Mercurial > hgrepos > Python2 > PyMuPDF
diff pipcl.py @ 39:a6bc019ac0b2 upstream
ADD: PyMuPDF v1.26.5: the original sdist.
| author | Franz Glasner <fzglas.hg@dom66.de> |
|---|---|
| date | Sat, 11 Oct 2025 11:19:58 +0200 |
| parents | 1d09e1dec1d9 |
| children | 71bcc18e306f |
line wrap: on
line diff
--- a/pipcl.py Mon Sep 15 11:43:07 2025 +0200 +++ b/pipcl.py Sat Oct 11 11:19:58 2025 +0200 @@ -2,23 +2,37 @@ Python packaging operations, including PEP-517 support, for use by a `setup.py` script. -The intention is to take care of as many packaging details as possible so that -setup.py contains only project-specific information, while also giving as much -flexibility as possible. - -For example we provide a function `build_extension()` that can be used to build -a SWIG extension, but we also give access to the located compiler/linker so -that a `setup.py` script can take over the details itself. - -Run doctests with: `python -m doctest pipcl.py` - -For Graal we require that PIPCL_GRAAL_PYTHON is set to non-graal Python (we -build for non-graal except with Graal Python's include paths and library -directory). +Overview: + + The intention is to take care of as many packaging details as possible so + that setup.py contains only project-specific information, while also giving + as much flexibility as possible. + + For example we provide a function `build_extension()` that can be used + to build a SWIG extension, but we also give access to the located + compiler/linker so that a `setup.py` script can take over the details + itself. + +Doctests: + Doctest strings are provided in some comments. + + Test in the usual way with: + python -m doctest pipcl.py + + Test specific functions/classes with: + python pipcl.py --doctest run_if ... + + If no functions or classes are specified, this tests everything. + +Graal: + For Graal we require that PIPCL_GRAAL_PYTHON is set to non-graal Python (we + build for non-graal except with Graal Python's include paths and library + directory). ''' import base64 import codecs +import difflib import glob import hashlib import inspect @@ -55,6 +69,9 @@ by legacy distutils/setuptools and described in: https://pip.pypa.io/en/stable/reference/build-system/setup-py/ + The file pyproject.toml must exist; this is checked if/when fn_build() is + called. + Here is a `doctest` example of using pipcl to create a SWIG extension module. Requires `swig`. @@ -321,63 +338,86 @@ wheel_compresslevel = None, ): ''' - The initial args before `root` define the package - metadata and closely follow the definitions in: + The initial args before `entry_points` define the + package metadata and closely follow the definitions in: https://packaging.python.org/specifications/core-metadata/ Args: name: + Used for metadata `Name`. A string, the name of the Python package. version: + Used for metadata `Version`. A string, the version of the Python package. Also see PEP-440 `Version Identification and Dependency Specification`. platform: + Used for metadata `Platform`. A string or list of strings. supported_platform: + Used for metadata `Supported-Platform`. A string or list of strings. summary: + Used for metadata `Summary`. A string, short description of the package. description: + Used for metadata `Description`. A string. If contains newlines, a detailed description of the package. Otherwise the path of a file containing the detailed description of the package. description_content_type: + Used for metadata `Description-Content-Type`. A string describing markup of `description` arg. For example `text/markdown; variant=GFM`. keywords: + Used for metadata `Keywords`. A string containing comma-separated keywords. home_page: + Used for metadata `Home-page`. URL of home page. download_url: + Used for metadata `Download-URL`. Where this version can be downloaded from. author: + Used for metadata `Author`. Author. author_email: + Used for metadata `Author-email`. Author email. maintainer: + Used for metadata `Maintainer`. Maintainer. maintainer_email: + Used for metadata `Maintainer-email`. Maintainer email. license: + Used for metadata `License`. A string containing the license text. Written into metadata file `COPYING`. Is also written into metadata itself if not multi-line. classifier: + Used for metadata `Classifier`. A string or list of strings. Also see: * https://pypi.org/pypi?%3Aaction=list_classifiers * https://pypi.org/classifiers/ requires_dist: - A string or list of strings. None items are ignored. Also see PEP-508. + Used for metadata `Requires-Dist`. + A string or list of strings, Python packages required + at runtime. None items are ignored. requires_python: + Used for metadata `Requires-Python`. A string or list of strings. requires_external: + Used for metadata `Requires-External`. A string or list of strings. project_url: - A string or list of strings, each of the form: `{name}, {url}`. + Used for metadata `Project-URL`. + A string or list of strings, each of the form: `{name}, + {url}`. provides_extra: + Used for metadata `Provides-Extra`. A string or list of strings. entry_points: @@ -415,8 +455,11 @@ added. `to_` identifies what the file should be called within a wheel - or when installing. If `to_` ends with `/`, the leaf of `from_` - is appended to it (and `from_` must not be a `bytes`). + or when installing. If `to_` is empty or `/` we set it to the + leaf of `from_` (`from_` must not be a `bytes`) - i.e. we place + the file in the root directory of the wheel; otherwise if + `to_` ends with `/` the leaf of `from_` is appended to it (and + `from_` must not be a `bytes`). Initial `$dist-info/` in `_to` is replaced by `{name}-{version}.dist-info/`; this is useful for license files @@ -439,6 +482,11 @@ default being `sysconfig.get_path('platlib')` e.g. `myvenv/lib/python3.9/site-packages/`. + When calling this function, we assert that the file + pyproject.toml exists in the current directory. (We do this + here rather than in pipcl.Package's constructor, as otherwise + importing setup.py from non-package-related code could fail.) + fn_clean: A function taking a single arg `all_` that cleans generated files. `all_` is true iff `--all` is in argv. @@ -457,8 +505,7 @@ It can be convenient to use `pipcl.git_items()`. The specification for sdists requires that the list contains - `pyproject.toml`; we enforce this with a diagnostic rather than - raising an exception, to allow legacy command-line usage. + `pyproject.toml`; we enforce this with a Python assert. tag_python: First element of wheel tag defined in PEP-425. If None we use @@ -528,6 +575,12 @@ assert_str_or_multi( requires_external) assert_str_or_multi( project_url) assert_str_or_multi( provides_extra) + + assert re.match('^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])\\Z', name, re.IGNORECASE), ( + f'Invalid package name' + f' (https://packaging.python.org/en/latest/specifications/name-normalization/)' + f': {name!r}' + ) # https://packaging.python.org/en/latest/specifications/core-metadata/. assert re.match('([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$', name, re.IGNORECASE), \ @@ -602,7 +655,10 @@ f' metadata_directory={metadata_directory!r}' ) - if sys.implementation.name == 'graalpy': + if os.environ.get('CIBUILDWHEEL') == '1': + # Don't special-case graal builds when running under cibuildwheel. + pass + elif sys.implementation.name == 'graalpy': # We build for Graal by building a native Python wheel with Graal # Python's include paths and library directory. We then rename the # wheel to contain graal's tag etc. @@ -754,7 +810,7 @@ else: items = self.fn_sdist() - prefix = f'{_normalise(self.name)}-{self.version}' + prefix = f'{_normalise2(self.name)}-{self.version}' os.makedirs(sdist_directory, exist_ok=True) tarpath = f'{sdist_directory}/{prefix}.tar.gz' log2(f'Creating sdist: {tarpath}') @@ -796,12 +852,11 @@ assert 0, f'Path is inside sdist_directory={sdist_directory}: {from_!r}' assert os.path.exists(from_), f'Path does not exist: {from_!r}' assert os.path.isfile(from_), f'Path is not a file: {from_!r}' - if to_rel == 'pyproject.toml': - found_pyproject_toml = True add(from_, to_rel) - - if not found_pyproject_toml: - log0(f'Warning: no pyproject.toml specified.') + if to_rel == 'pyproject.toml': + found_pyproject_toml = True + + assert found_pyproject_toml, f'Cannot create sdist because file not specified: pyproject.toml' # Always add a PKG-INFO file. add_string(self._metainfo(), 'PKG-INFO') @@ -826,9 +881,11 @@ Get two-digit python version, e.g. 'cp3.8' for python-3.8.6. ''' if self.tag_python_: - return self.tag_python_ + ret = self.tag_python_ else: - return 'cp' + ''.join(platform.python_version().split('.')[:2]) + ret = 'cp' + ''.join(platform.python_version().split('.')[:2]) + assert '-' not in ret + return ret def tag_abi(self): ''' @@ -884,10 +941,13 @@ ret = ret2 log0( f'tag_platform(): returning {ret=}.') + assert '-' not in ret return ret def wheel_name(self): - return f'{_normalise(self.name)}-{self.version}-{self.tag_python()}-{self.tag_abi()}-{self.tag_platform()}.whl' + ret = f'{_normalise2(self.name)}-{self.version}-{self.tag_python()}-{self.tag_abi()}-{self.tag_platform()}.whl' + assert ret.count('-') == 4, f'Expected 4 dash characters in {ret=}.' + return ret def wheel_name_match(self, wheel): ''' @@ -916,7 +976,7 @@ log2(f'py_limited_api; {tag_python=} compatible with {self.tag_python()=}.') py_limited_api_compatible = True - log2(f'{_normalise(self.name) == name=}') + log2(f'{_normalise2(self.name) == name=}') log2(f'{self.version == version=}') log2(f'{self.tag_python() == tag_python=} {self.tag_python()=} {tag_python=}') log2(f'{py_limited_api_compatible=}') @@ -925,7 +985,7 @@ log2(f'{self.tag_platform()=}') log2(f'{tag_platform.split(".")=}') ret = (1 - and _normalise(self.name) == name + and _normalise2(self.name) == name and self.version == version and (self.tag_python() == tag_python or py_limited_api_compatible) and self.tag_abi() == tag_abi @@ -947,6 +1007,9 @@ def _call_fn_build( self, config_settings=None): assert self.fn_build + assert os.path.isfile('pyproject.toml'), ( + 'Cannot create package because file does not exist: pyproject.toml' + ) log2(f'calling self.fn_build={self.fn_build}') if inspect.signature(self.fn_build).parameters: ret = self.fn_build(config_settings) @@ -954,6 +1017,28 @@ ret = self.fn_build() assert isinstance( ret, (list, tuple)), \ f'Expected list/tuple from {self.fn_build} but got: {ret!r}' + + # Check that any extensions that we have built, have same + # py_limited_api value. If package is marked with py_limited_api=True + # then non-py_limited_api extensions seem to fail at runtime on + # Windows. + # + # (We could possibly allow package py_limited_api=False and extensions + # py_limited_api=True, but haven't tested this, and it seems simpler to + # be strict.) + for item in ret: + from_, (to_abs, to_rel) = self._fromto(item) + from_abs = os.path.abspath(from_) + is_py_limited_api = _extensions_to_py_limited_api.get(from_abs) + if is_py_limited_api is not None: + assert bool(self.py_limited_api) == bool(is_py_limited_api), ( + f'Extension was built with' + f' py_limited_api={is_py_limited_api} but pipcl.Package' + f' name={self.name!r} has' + f' py_limited_api={self.py_limited_api}:' + f' {from_abs!r}' + ) + return ret @@ -1052,7 +1137,7 @@ it writes to a slightly different directory. ''' if root is None: - root = f'{self.name}-{self.version}.dist-info' + root = f'{normalise2(self.name)}-{self.version}.dist-info' self._write_info(f'{root}/METADATA') if self.license: with open( f'{root}/COPYING', 'w') as f: @@ -1340,7 +1425,7 @@ ) def _dist_info_dir( self): - return f'{_normalise(self.name)}-{self.version}.dist-info' + return f'{_normalise2(self.name)}-{self.version}.dist-info' def _metainfo(self): ''' @@ -1446,8 +1531,12 @@ `p` is a tuple `(from_, to_)` where `from_` is str/bytes and `to_` is str. If `from_` is a bytes it is contents of file to add, otherwise the path of an existing file; non-absolute paths are assumed to be relative - to `self.root`. If `to_` is empty or ends with `/`, we append the leaf - of `from_` (which must be a str). + to `self.root`. + + If `to_` is empty or `/` we set it to the leaf of `from_` (which must + be a str) - i.e. we place the file in the root directory of the wheel; + otherwise if `to_` ends with `/` we append the leaf of `from_` (which + must be a str). If `to_` starts with `$dist-info/`, we replace this with `self._dist_info_dir()`. @@ -1467,14 +1556,16 @@ from_, to_ = p assert isinstance(from_, (str, bytes)) assert isinstance(to_, str) - if to_.endswith('/') or to_=='': + if to_ == '/' or to_ == '': + to_ = os.path.basename(from_) + elif to_.endswith('/'): to_ += os.path.basename(from_) prefix = '$dist-info/' if to_.startswith( prefix): to_ = f'{self._dist_info_dir()}/{to_[ len(prefix):]}' prefix = '$data/' if to_.startswith( prefix): - to_ = f'{self.name}-{self.version}.data/{to_[ len(prefix):]}' + to_ = f'{_normalise2(self.name)}-{self.version}.data/{to_[ len(prefix):]}' if isinstance(from_, str): from_, _ = self._path_relative_to_root( from_, assert_within_root=False) to_ = self._path_relative_to_root(to_) @@ -1482,11 +1573,13 @@ log2(f'returning {from_=} {to_=}') return from_, to_ +_extensions_to_py_limited_api = dict() def build_extension( name, path_i, outdir, + *, builddir=None, includes=None, defines=None, @@ -1498,6 +1591,7 @@ linker_extra='', swig=None, cpp=True, + source_extra=None, prerequisites_swig=None, prerequisites_compile=None, prerequisites_link=None, @@ -1539,7 +1633,7 @@ A string, or a sequence of library names. Each item is prefixed with `-l` on non-Windows. optimise: - Whether to use compiler optimisations. + Whether to use compiler optimisations and define NDEBUG. debug: Whether to build with debug symbols. compiler_extra: @@ -1550,6 +1644,8 @@ Swig command; if false we use 'swig'. cpp: If true we tell SWIG to generate C++ code instead of C. + source_extra: + Extra source files to build into the shared library, prerequisites_swig: prerequisites_compile: prerequisites_link: @@ -1584,10 +1680,15 @@ `compile_extra` (also `/I` on windows) and use them with swig so that it can see the same header files as C/C++. This is useful when using enviromment variables such as `CC` and `CXX` to set - `compile_extra. + `compile_extra`. py_limited_api: If true we build for current Python's limited API / stable ABI. + Note that we will assert false if this extension is added to a + pipcl.Package that has a different <py_limited_api>, because + on Windows importing a non-py_limited_api extension inside a + py_limited=True package fails. + Returns the leafname of the generated library file within `outdir`, e.g. `_{name}.so` on Unix or `_{name}.cp311-win_amd64.pyd` on Windows. ''' @@ -1599,6 +1700,12 @@ builddir = outdir if not swig: swig = 'swig' + + if source_extra is None: + source_extra = list() + if isinstance(source_extra, str): + source_extra = [source_extra] + includes_text = _flags( includes, '-I') defines_text = _flags( defines, '-D') libpaths_text = _flags( libpaths, '/LIBPATH:', '"') if windows() else _flags( libpaths, '-L') @@ -1608,11 +1715,11 @@ os.makedirs( outdir, exist_ok=True) # Run SWIG. - + # if infer_swig_includes: # Extract include flags from `compiler_extra`. swig_includes_extra = '' - compiler_extra_items = compiler_extra.split() + compiler_extra_items = shlex.split(compiler_extra) i = 0 while i < len(compiler_extra_items): item = compiler_extra_items[i] @@ -1647,75 +1754,130 @@ prerequisites_swig2, ) - so_suffix = _so_suffix(use_so_versioning = not py_limited_api) + if pyodide(): + so_suffix = '.so' + log0(f'pyodide: PEP-3149 suffix untested, so omitting. {_so_suffix()=}.') + else: + so_suffix = _so_suffix(use_so_versioning = not py_limited_api) path_so_leaf = f'_{name}{so_suffix}' path_so = f'{outdir}/{path_so_leaf}' py_limited_api2 = current_py_limited_api() if py_limited_api else None + compiler_command, pythonflags = base_compiler(cpp=cpp) + linker_command, _ = base_linker(cpp=cpp) + # setuptools on Linux seems to use slightly different compile flags: + # + # -fwrapv -O3 -Wall -O2 -g0 -DPY_CALL_TRAMPOLINE + # + + general_flags = '' if windows(): - path_obj = f'{path_so}.obj' - permissive = '/permissive-' EHsc = '/EHsc' T = '/Tp' if cpp else '/Tc' optimise2 = '/DNDEBUG /O2' if optimise else '/D_DEBUG' - debug2 = '' - if debug: - debug2 = '/Zi' # Generate .pdb. - # debug2 = '/Z7' # Embed debug info in .obj files. - + debug2 = '/Zi' if debug else '' py_limited_api3 = f'/DPy_LIMITED_API={py_limited_api2}' if py_limited_api2 else '' - # As of 2023-08-23, it looks like VS tools create slightly - # .dll's each time, even with identical inputs. - # - # Some info about this is at: - # https://nikhilism.com/post/2020/windows-deterministic-builds/. - # E.g. an undocumented linker flag `/Brepro`. + else: + if debug: + general_flags += '/Zi' if windows() else ' -g' + if optimise: + general_flags += ' /DNDEBUG /O2' if windows() else ' -O2 -DNDEBUG' + + py_limited_api3 = f'-DPy_LIMITED_API={py_limited_api2}' if py_limited_api2 else '' + + if windows(): + pass + elif darwin(): + # MacOS's linker does not like `-z origin`. + rpath_flag = "-Wl,-rpath,@loader_path/" + # Avoid `Undefined symbols for ... "_PyArg_UnpackTuple" ...'. + general_flags += ' -undefined dynamic_lookup' + elif pyodide(): + # Setting `-Wl,-rpath,'$ORIGIN',-z,origin` gives: + # emcc: warning: ignoring unsupported linker flag: `-rpath` [-Wlinkflags] + # wasm-ld: error: unknown -z value: origin # - - command, pythonflags = base_compiler(cpp=cpp) - command = f''' - {command} - # General: - /c # Compiles without linking. - {EHsc} # Enable "Standard C++ exception handling". - - #/MD # Creates a multithreaded DLL using MSVCRT.lib. - {'/MDd' if debug else '/MD'} - - # Input/output files: - {T}{path_cpp} # /Tp specifies C++ source file. - /Fo{path_obj} # Output file. codespell:ignore - - # Include paths: - {includes_text} - {pythonflags.includes} # Include path for Python headers. - - # Code generation: - {optimise2} - {debug2} - {permissive} # Set standard-conformance mode. - - # Diagnostics: - #/FC # Display full path of source code files passed to cl.exe in diagnostic text. - /W3 # Sets which warning level to output. /W3 is IDE default. - /diagnostics:caret # Controls the format of diagnostic messages. - /nologo # - - {defines_text} - {compiler_extra} - - {py_limited_api3} - ''' - run_if( command, path_obj, path_cpp, prerequisites_compile) - - command, pythonflags = base_linker(cpp=cpp) + rpath_flag = "-Wl,-rpath,'$ORIGIN'" + else: + rpath_flag = "-Wl,-rpath,'$ORIGIN',-z,origin" + + # Fun fact - on Linux, if the -L and -l options are before '{path_cpp}' + # they seem to be ignored... + # + path_os = list() + + for path_source in [path_cpp] + source_extra: + path_o = f'{path_source}.obj' if windows() else f'{path_source}.o' + path_os.append(f' {path_o}') + + prerequisites_path = f'{path_o}.d' + + if windows(): + compiler_command2 = f''' + {compiler_command} + # General: + /c # Compiles without linking. + {EHsc} # Enable "Standard C++ exception handling". + + #/MD # Creates a multithreaded DLL using MSVCRT.lib. + {'/MDd' if debug else '/MD'} + + # Input/output files: + {T}{path_source} # /Tp specifies C++ source file. + /Fo{path_o} # Output file. codespell:ignore + + # Include paths: + {includes_text} + {pythonflags.includes} # Include path for Python headers. + + # Code generation: + {optimise2} + {debug2} + {permissive} # Set standard-conformance mode. + + # Diagnostics: + #/FC # Display full path of source code files passed to cl.exe in diagnostic text. + /W3 # Sets which warning level to output. /W3 is IDE default. + /diagnostics:caret # Controls the format of diagnostic messages. + /nologo # + + {defines_text} + {compiler_extra} + + {py_limited_api3} + ''' + + else: + compiler_command2 = f''' + {compiler_command} + -fPIC + {general_flags.strip()} + {pythonflags.includes} + {includes_text} + {defines_text} + -MD -MF {prerequisites_path} + -c {path_source} + -o {path_o} + {compiler_extra} + {py_limited_api3} + ''' + run_if( + compiler_command2, + path_o, + path_source, + [path_source] + _get_prerequisites(prerequisites_path), + ) + + # Link + prerequisites_path = f'{path_so}.d' + if windows(): debug2 = '/DEBUG' if debug else '' base, _ = os.path.splitext(path_so_leaf) - command = f''' - {command} + command2 = f''' + {linker_command} /DLL # Builds a DLL. /EXPORT:PyInit__{name} # Exports a function. /IMPLIB:{base}.lib # Overrides the default import library name. @@ -1725,139 +1887,67 @@ {debug2} /nologo {libs_text} - {path_obj} + {' '.join(path_os)} {linker_extra} ''' - run_if( command, path_so, path_obj, prerequisites_link) - + elif pyodide(): + command2 = f''' + {linker_command} + -MD -MF {prerequisites_path} + -o {path_so} + {' '.join(path_os)} + {libpaths_text} + {libs_text} + {linker_extra} + {pythonflags.ldflags} + {rpath_flag} + ''' else: - - # Not Windows. - # - command, pythonflags = base_compiler(cpp=cpp) - - # setuptools on Linux seems to use slightly different compile flags: - # - # -fwrapv -O3 -Wall -O2 -g0 -DPY_CALL_TRAMPOLINE - # - - general_flags = '' - if debug: - general_flags += ' -g' - if optimise: - general_flags += ' -O2 -DNDEBUG' - - py_limited_api3 = f'-DPy_LIMITED_API={py_limited_api2}' if py_limited_api2 else '' - - if darwin(): - # MacOS's linker does not like `-z origin`. - rpath_flag = "-Wl,-rpath,@loader_path/" - - # Avoid `Undefined symbols for ... "_PyArg_UnpackTuple" ...'. - general_flags += ' -undefined dynamic_lookup' - elif pyodide(): - # Setting `-Wl,-rpath,'$ORIGIN',-z,origin` gives: - # emcc: warning: ignoring unsupported linker flag: `-rpath` [-Wlinkflags] - # wasm-ld: error: unknown -z value: origin - # - log0(f'pyodide: PEP-3149 suffix untested, so omitting. {_so_suffix()=}.') - path_so_leaf = f'_{name}.so' - path_so = f'{outdir}/{path_so_leaf}' - - rpath_flag = '' - else: - rpath_flag = "-Wl,-rpath,'$ORIGIN',-z,origin" - path_so = f'{outdir}/{path_so_leaf}' - # Fun fact - on Linux, if the -L and -l options are before '{path_cpp}' - # they seem to be ignored... - # - prerequisites = list() - - if pyodide(): - # Looks like pyodide's `cc` can't compile and link in one invocation. - prerequisites_compile_path = f'{path_cpp}.o.d' - prerequisites += _get_prerequisites( prerequisites_compile_path) - command = f''' - {command} - -fPIC - {general_flags.strip()} - {pythonflags.includes} - {includes_text} - {defines_text} - -MD -MF {prerequisites_compile_path} - -c {path_cpp} - -o {path_cpp}.o - {compiler_extra} - {py_limited_api3} - ''' - prerequisites_link_path = f'{path_cpp}.o.d' - prerequisites += _get_prerequisites( prerequisites_link_path) - ld, _ = base_linker(cpp=cpp) - command += f''' - && {ld} - {path_cpp}.o - -o {path_so} - -MD -MF {prerequisites_link_path} - {rpath_flag} - {libpaths_text} - {libs_text} - {linker_extra} - {pythonflags.ldflags} - ''' - else: - # We use compiler to compile and link in one command. - prerequisites_path = f'{path_so}.d' - prerequisites = _get_prerequisites(prerequisites_path) - - command = f''' - {command} - -fPIC - -shared - {general_flags.strip()} - {pythonflags.includes} - {includes_text} - {defines_text} - {path_cpp} - -MD -MF {prerequisites_path} - -o {path_so} - {compiler_extra} - {libpaths_text} - {linker_extra} - {pythonflags.ldflags} - {libs_text} - {rpath_flag} - {py_limited_api3} - ''' - command_was_run = run_if( - command, - path_so, - path_cpp, - prerequisites_compile, - prerequisites_link, - prerequisites, - ) - - if command_was_run and darwin(): - # We need to patch up references to shared libraries in `libs`. - sublibraries = list() - for lib in () if libs is None else libs: - for libpath in libpaths: - found = list() - for suffix in '.so', '.dylib': - path = f'{libpath}/lib{os.path.basename(lib)}{suffix}' - if os.path.exists( path): - found.append( path) - if found: - assert len(found) == 1, f'More than one file matches lib={lib!r}: {found}' - sublibraries.append( found[0]) - break - else: - log2(f'Warning: can not find path of lib={lib!r} in libpaths={libpaths}') - macos_patch( path_so, *sublibraries) + command2 = f''' + {linker_command} + -shared + {general_flags.strip()} + -MD -MF {prerequisites_path} + -o {path_so} + {' '.join(path_os)} + {libpaths_text} + {libs_text} + {linker_extra} + {pythonflags.ldflags} + {rpath_flag} + {py_limited_api3} + ''' + link_was_run = run_if( + command2, + path_so, + path_cpp, + *path_os, + *_get_prerequisites(f'{path_so}.d'), + ) + + if link_was_run and darwin(): + # We need to patch up references to shared libraries in `libs`. + sublibraries = list() + for lib in () if libs is None else libs: + for libpath in libpaths: + found = list() + for suffix in '.so', '.dylib': + path = f'{libpath}/lib{os.path.basename(lib)}{suffix}' + if os.path.exists( path): + found.append( path) + if found: + assert len(found) == 1, f'More than one file matches lib={lib!r}: {found}' + sublibraries.append( found[0]) + break + else: + log2(f'Warning: can not find path of lib={lib!r} in libpaths={libpaths}') + macos_patch( path_so, *sublibraries) #run(f'ls -l {path_so}', check=0) #run(f'file {path_so}', check=0) + _extensions_to_py_limited_api[os.path.abspath(path_so)] = py_limited_api + return path_so_leaf @@ -1983,7 +2073,7 @@ ) if not e: branch = out.strip() - log(f'git_info(): directory={directory!r} returning branch={branch!r} sha={sha!r} comment={comment!r}') + log1(f'git_info(): directory={directory!r} returning branch={branch!r} sha={sha!r} comment={comment!r}') return sha, comment, diff, branch @@ -2027,88 +2117,96 @@ def git_get( - remote, local, *, + remote=None, branch=None, + tag=None, + text=None, depth=1, env_extra=None, - tag=None, update=True, submodules=True, - default_remote=None, ): ''' - Ensures that <local> is a git checkout (at either <tag>, or <branch> HEAD) - of a remote repository. - - Exactly one of <branch> and <tag> must be specified, or <remote> must start - with 'git:' and match the syntax described below. + Creates/updates local checkout <local> of remote repository and returns + absolute path of <local>. + + If <text> is set but does not start with 'git:', it is assumed to be an up + to date local checkout, and we return absolute path of <text> without doing + any git operations. Args: + local: + Local directory. Created and/or updated using `git clone` and `git + fetch` etc. remote: Remote git repostitory, for example - 'https://github.com/ArtifexSoftware/mupdf.git'. + 'https://github.com/ArtifexSoftware/mupdf.git'. Can be overridden + by <text>. + branch: + Branch to use; can be overridden by <text>. + tag: + Tag to use; can be overridden by <text>. + text: + If None or empty: + Ignored. - If starts with 'git:', the remaining text should be a command-line - style string containing some or all of these args: - --branch <branch> - --tag <tag> - <remote> - These overrides <branch>, <tag> and <default_remote>. + If starts with 'git:': + The remaining text should be a command-line + style string containing some or all of these args: + --branch <branch> + --tag <tag> + <remote> + These overrides <branch>, <tag> and <remote>. + Otherwise: + <text> is assumed to be a local directory, and we simply return + it as an absolute path without doing any git operations. For example these all clone/update/branch master of https://foo.bar/qwerty.git to local checkout 'foo-local': - git_get('https://foo.bar/qwerty.git', 'foo-local', branch='master') - git_get('git:--branch master https://foo.bar/qwerty.git', 'foo-local') - git_get('git:--branch master', 'foo-local', default_remote='https://foo.bar/qwerty.git') - git_get('git:', 'foo-local', branch='master', default_remote='https://foo.bar/qwerty.git') - - local: - Local directory. If <local>/.git exists, we attempt to run `git - update` in it. - branch: - Branch to use. Is used as default if remote starts with 'git:'. + git_get('foo-local', remote='https://foo.bar/qwerty.git', branch='master') + git_get('foo-local', text='git:--branch master https://foo.bar/qwerty.git') + git_get('foo-local', text='git:--branch master', remote='https://foo.bar/qwerty.git') + git_get('foo-local', text='git:', branch='master', remote='https://foo.bar/qwerty.git') depth: Depth of local checkout when cloning and fetching, or None. env_extra: Dict of extra name=value environment variables to use whenever we run git. - tag: - Tag to use. Is used as default if remote starts with 'git:'. update: If false we do not update existing repository. Might be useful if testing without network access. submodules: If true, we clone with `--recursive --shallow-submodules` and run `git submodule update --init --recursive` before returning. - default_remote: - The remote URL if <remote> starts with 'git:' but does not specify - the remote URL. ''' log0(f'{remote=} {local=} {branch=} {tag=}') - if remote.startswith('git:'): - remote0 = remote - args = iter(shlex.split(remote0[len('git:'):])) - remote = default_remote - while 1: - try: - arg = next(args) - except StopIteration: - break - if arg == '--branch': - branch = next(args) - tag = None - elif arg == '--tag': - tag == next(args) - branch = None - else: - remote = arg - assert remote, f'{default_remote=} and no remote specified in remote={remote0!r}.' - assert branch or tag, f'{branch=} {tag=} and no branch/tag specified in remote={remote0!r}.' + + if text: + if text.startswith('git:'): + args = iter(shlex.split(text[len('git:'):])) + while 1: + try: + arg = next(args) + except StopIteration: + break + if arg == '--branch': + branch = next(args) + tag = None + elif arg == '--tag': + tag = next(args) + branch = None + else: + remote = arg + assert remote, f'<remote> unset and no remote specified in {text=}.' + assert branch or tag, f'<branch> and <tag> unset and no branch/tag specified in {text=}.' + else: + log0(f'Using local directory {text!r}.') + return os.path.abspath(text) - assert (branch and not tag) or (not branch and tag), f'Must specify exactly one of <branch> and <tag>.' + assert (branch and not tag) or (not branch and tag), f'Must specify exactly one of <branch> and <tag>; {branch=} {tag=}.' depth_arg = f' --depth {depth}' if depth else '' @@ -2116,7 +2214,7 @@ # This seems to pull in the entire repository. log0(f'do_update(): attempting to update {local=}.') # Remove any local changes. - run(f'cd {local} && git checkout .', env_extra=env_extra) + run(f'cd {local} && git reset --hard', env_extra=env_extra) if tag: # `-u` avoids `fatal: Refusing to fetch into current branch`. # Using '+' and `revs/tags/` prefix seems to avoid errors like: @@ -2164,6 +2262,7 @@ # Show sha of checkout. run( f'cd {local} && git show --pretty=oneline|head -n 1', check=False) + return os.path.abspath(local) def run( @@ -2452,10 +2551,11 @@ log2(f'### Have removed `-lcrypt` from ldflags: {self.ldflags!r} -> {ldflags2!r}') self.ldflags = ldflags2 - log1(f'{self.includes=}') - log1(f' {includes_=}') - log1(f'{self.ldflags=}') - log1(f' {ldflags_=}') + if 0: + log1(f'{self.includes=}') + log1(f' {includes_=}') + log1(f'{self.ldflags=}') + log1(f' {ldflags_=}') def macos_add_cross_flags(command): @@ -2555,7 +2655,7 @@ return f'x{32 if sys.maxsize == 2**31 - 1 else 64}' -def run_if( command, out, *prerequisites): +def run_if( command, out, *prerequisites, caller=1): ''' Runs a command only if the output file is not up to date. @@ -2585,21 +2685,26 @@ ... os.remove( out) >>> if os.path.exists( f'{out}.cmd'): ... os.remove( f'{out}.cmd') - >>> run_if( f'touch {out}', out) + >>> run_if( f'touch {out}', out, caller=0) pipcl.py:run_if(): Running command because: File does not exist: 'run_if_test_out' pipcl.py:run_if(): Running: touch run_if_test_out True If we repeat, the output file will be up to date so the command is not run: - >>> run_if( f'touch {out}', out) + >>> run_if( f'touch {out}', out, caller=0) pipcl.py:run_if(): Not running command because up to date: 'run_if_test_out' If we change the command, the command is run: - >>> run_if( f'touch {out}', out) - pipcl.py:run_if(): Running command because: Command has changed - pipcl.py:run_if(): Running: touch run_if_test_out + >>> run_if( f'touch {out};', out, caller=0) + pipcl.py:run_if(): Running command because: Command has changed: + pipcl.py:run_if(): @@ -1,2 +1,2 @@ + pipcl.py:run_if(): touch + pipcl.py:run_if(): -run_if_test_out + pipcl.py:run_if(): +run_if_test_out; + pipcl.py:run_if(): + pipcl.py:run_if(): Running: touch run_if_test_out; True If we add a prerequisite that is newer than the output, the command is run: @@ -2608,15 +2713,20 @@ >>> prerequisite = 'run_if_test_prerequisite' >>> run( f'touch {prerequisite}', caller=0) pipcl.py:run(): Running: touch run_if_test_prerequisite - >>> run_if( f'touch {out}', out, prerequisite) - pipcl.py:run_if(): Running command because: Prerequisite is new: 'run_if_test_prerequisite' + >>> run_if( f'touch {out}', out, prerequisite, caller=0) + pipcl.py:run_if(): Running command because: Command has changed: + pipcl.py:run_if(): @@ -1,2 +1,2 @@ + pipcl.py:run_if(): touch + pipcl.py:run_if(): -run_if_test_out; + pipcl.py:run_if(): +run_if_test_out + pipcl.py:run_if(): pipcl.py:run_if(): Running: touch run_if_test_out True If we repeat, the output will be newer than the prerequisite, so the command is not run: - >>> run_if( f'touch {out}', out, prerequisite) + >>> run_if( f'touch {out}', out, prerequisite, caller=0) pipcl.py:run_if(): Not running command because up to date: 'run_if_test_out' ''' doit = False @@ -2633,13 +2743,34 @@ cmd = f.read() else: cmd = None - if command != cmd: + cmd_args = shlex.split(cmd or '') + command_args = shlex.split(command or '') + if command_args != cmd_args: if cmd is None: doit = 'No previous command stored' else: doit = f'Command has changed' if 0: - doit += f': {cmd!r} => {command!r}' + doit += f':\n {cmd!r}\n {command!r}' + if 0: + doit += f'\nbefore:\n' + doit += textwrap.indent(cmd, ' ') + doit += f'\nafter:\n' + doit += textwrap.indent(command, ' ') + if 1: + # Show diff based on commands split into pseudo lines by + # shlex.split(). + doit += ':\n' + lines = difflib.unified_diff( + cmd.split(), + command.split(), + lineterm='', + ) + # Skip initial lines. + assert next(lines) == '--- ' + assert next(lines) == '+++ ' + for line in lines: + doit += f' {line}\n' if not doit: # See whether any prerequisites are newer than target. @@ -2652,9 +2783,9 @@ for p in prerequisites: prerequisites_all += _make_prerequisites( p) if 0: - log2( 'prerequisites_all:') + log2( 'prerequisites_all:', caller=caller+1) for i in prerequisites_all: - log2( f' {i!r}') + log2( f' {i!r}', caller=caller+1) pre_mtime = 0 pre_path = None for prerequisite in prerequisites_all: @@ -2670,7 +2801,7 @@ break if not doit: if pre_mtime > out_mtime: - doit = f'Prerequisite is new: {pre_path!r}' + doit = f'Prerequisite is new: {os.path.abspath(pre_path)!r}' if doit: # Remove `cmd_path` before we run the command, so any failure @@ -2680,16 +2811,16 @@ os.remove( cmd_path) except Exception: pass - log1( f'Running command because: {doit}') - - run( command) + log1( f'Running command because: {doit}', caller=caller+1) + + run( command, caller=caller+1) # Write the command we ran, into `cmd_path`. with open( cmd_path, 'w') as f: f.write( command) return True else: - log1( f'Not running command because up to date: {out!r}') + log1( f'Not running command because up to date: {out!r}', caller=caller+1) if 0: log2( f'out_mtime={time.ctime(out_mtime)} pre_mtime={time.ctime(pre_mtime)}.' @@ -2761,6 +2892,11 @@ return re.sub(r"[-_.]+", "-", name).lower() +def _normalise2(name): + # https://packaging.python.org/en/latest/specifications/binary-distribution-format/ + return _normalise(name).replace('-', '_') + + def _assert_version_pep_440(version): assert re.match( r'^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$', @@ -2790,6 +2926,9 @@ global g_log_line_numbers g_log_line_numbers = bool(yes) +def log(text='', caller=1): + _log(text, 0, caller+1) + def log0(text='', caller=1): _log(text, 0, caller+1) @@ -2813,19 +2952,30 @@ print(f'{filename}:{fr.function}(): {line}', file=sys.stdout, flush=1) -def relpath(path, start=None): +def relpath(path, start=None, allow_up=True): ''' A safe alternative to os.path.relpath(), avoiding an exception on Windows if the drive needs to change - in this case we use os.path.abspath(). + + Args: + path: + Path to be processed. + start: + Start directory or current directory if None. + allow_up: + If false we return absolute path is <path> is not within <start>. ''' if windows(): try: - return os.path.relpath(path, start) + ret = os.path.relpath(path, start) except ValueError: # os.path.relpath() fails if trying to change drives. - return os.path.abspath(path) + ret = os.path.abspath(path) else: - return os.path.relpath(path, start) + ret = os.path.relpath(path, start) + if not allow_up and ret.startswith('../') or ret.startswith('..\\'): + ret = os.path.abspath(path) + return ret def _so_suffix(use_so_versioning=True): @@ -2981,21 +3131,22 @@ for path, id_ in items.items(): id0 = self.items0.get(path) if id0 != id_: - #mtime0, hash0 = id0 - #mtime1, hash1 = id_ - #log0(f'New/modified file {path=}.') - #log0(f' {mtime0=} {"==" if mtime0==mtime1 else "!="} {mtime1=}.') - #log0(f' {hash0=} {"==" if hash0==hash1 else "!="} {hash1=}.') ret.append(path) return ret + def get_n(self, n): + ''' + Returns new files matching <glob_pattern>, asserting that there are + exactly <n>. + ''' + ret = self.get() + assert len(ret) == n, f'{len(ret)=}: {ret}' + return ret def get_one(self): ''' Returns new match of <glob_pattern>, asserting that there is exactly one. ''' - ret = self.get() - assert len(ret) == 1, f'{len(ret)=}' - return ret[0] + return self.get_n(1)[0] def _file_id(self, path): mtime = os.stat(path).st_mtime with open(path, 'rb') as f: @@ -3025,7 +3176,7 @@ Args: swig: - If starts with 'git:', passed as <remote> arg to git_remote(). + If starts with 'git:', passed as <text> arg to git_get(). quick: If true, we do not update/build local checkout if the binary is already present. @@ -3033,9 +3184,8 @@ path to use for checkout. ''' if swig and swig.startswith('git:'): - assert platform.system() != 'Windows' - swig_local = os.path.abspath(swig_local) - # Note that {swig_local}/install/bin/swig doesn't work on MacoS because + assert platform.system() != 'Windows', f'Cannot build swig on Windows.' + # Note that {swig_local}/install/bin/swig doesn't work on MacOS because # {swig_local}/INSTALL is a file and the fs is case-insensitive. swig_binary = f'{swig_local}/install-dir/bin/swig' if quick and os.path.isfile(swig_binary): @@ -3043,10 +3193,10 @@ else: # Clone swig. swig_env_extra = None - git_get( - swig, + swig_local = git_get( swig_local, - default_remote='https://github.com/swig/swig.git', + text=swig, + remote='https://github.com/swig/swig.git', branch='master', ) if darwin(): @@ -3061,10 +3211,10 @@ # > If you need to have bison first in your PATH, run: # > echo 'export PATH="/opt/homebrew/opt/bison/bin:$PATH"' >> ~/.zshrc # - run(f'brew install bison') - PATH = os.environ['PATH'] - PATH = f'/opt/homebrew/opt/bison/bin:{PATH}' - swig_env_extra = dict(PATH=PATH) + swig_env_extra = dict() + macos_add_brew_path('bison', swig_env_extra) + run(f'which bison') + run(f'which bison', env_extra=swig_env_extra) # Build swig. run(f'cd {swig_local} && ./autogen.sh', env_extra=swig_env_extra) run(f'cd {swig_local} && ./configure --prefix={swig_local}/install-dir', env_extra=swig_env_extra) @@ -3076,6 +3226,38 @@ return swig +def macos_add_brew_path(package, env=None, gnubin=True): + ''' + Adds path(s) for Brew <package>'s binaries to env['PATH']. + + Args: + package: + Name of package. We get <package_root> of installed package by + running `brew --prefix <package>`. + env: + The environment dict to modify. If None we use os.environ. If PATH + is not in <env>, we first copy os.environ['PATH'] into <env>. + gnubin: + If true, we also add path to gnu binaries if it exists, + <package_root>/libexe/gnubin. + ''' + if not darwin(): + return + if env is None: + env = os.environ + if 'PATH' not in env: + env['PATH'] = os.environ['PATH'] + package_root = run(f'brew --prefix {package}', capture=1).strip() + def add(path): + if os.path.isdir(path): + log1(f'Adding to $PATH: {path}') + PATH = env['PATH'] + env['PATH'] = f'{path}:{PATH}' + add(f'{package_root}/bin') + if gnubin: + add(f'{package_root}/libexec/gnubin') + + def _show_dict(d): ret = '' for n in sorted(d.keys()): @@ -3119,12 +3301,76 @@ return includes_, ldflags_ +def venv_in(path=None): + ''' + If path is None, returns true if we are in a venv. Otherwise returns true + only if we are in venv <path>. + ''' + if path: + return os.path.abspath(sys.prefix) == os.path.abspath(path) + else: + return sys.prefix != sys.base_prefix + + +def venv_run(args, path, recreate=True, clean=False): + ''' + Runs Python command inside venv and returns termination code. + + Args: + args: + List of args or string command. + path: + Path of venv directory. + recreate: + If false we do not run `<sys.executable> -m venv <path>` if <path> + already exists. This avoids a delay in the common case where <path> + is already set up, but fails if <path> exists but does not contain + a valid venv. + clean: + If true we first delete <path>. + ''' + if clean: + log(f'Removing any existing venv {path}.') + assert path.startswith('venv-') + shutil.rmtree(path, ignore_errors=1) + if recreate or not os.path.isdir(path): + run(f'{sys.executable} -m venv {path}') + + if isinstance(args, str): + args_string = args + elif platform.system() == 'Windows': + # shlex not reliable on Windows so we use Use crude quoting with "...". + args_string = '' + for i, arg in enumerate(args): + assert '"' not in arg + if i: + args_string += ' ' + args_string += f'"{arg}"' + else: + args_string = shlex.join(args) + + if platform.system() == 'Windows': + command = f'{path}\\Scripts\\activate && python {args_string}' + else: + command = f'. {path}/bin/activate && python {args_string}' + e = run(command, check=0) + return e + + if __name__ == '__main__': # Internal-only limited command line support, used if # graal_legacy_python_config is true. # includes, ldflags = sysconfig_python_flags() - if sys.argv[1:] == ['--graal-legacy-python-config', '--includes']: + if sys.argv[1] == '--doctest': + import doctest + if sys.argv[2:]: + for f in sys.argv[2:]: + ff = globals()[f] + doctest.run_docstring_examples(ff, globals()) + else: + doctest.testmod(None) + elif sys.argv[1:] == ['--graal-legacy-python-config', '--includes']: print(includes) elif sys.argv[1:] == ['--graal-legacy-python-config', '--ldflags']: print(ldflags)
