374 lines
13 KiB
Python
374 lines
13 KiB
Python
# SPDX-License-Identifier: BSD-2-Clause
|
|||
|
|
|
||
|
|
# Copyright (c) 2025 Phil Thompson <phil@riverbankcomputing.com>
|
||
|
|
|
||
|
|
|
||
|
|
from abc import abstractmethod
|
||
|
|
import glob
|
||
|
|
import os
|
||
|
|
import shutil
|
||
|
|
import stat
|
||
|
|
import sys
|
||
|
|
|
||
|
|
from .abstract_builder import AbstractBuilder
|
||
|
|
from .distinfo import write_metadata
|
||
|
|
from .exceptions import UserException
|
||
|
|
from .installable import Installable
|
||
|
|
from .module import copy_sip_h, copy_sip_pyi
|
||
|
|
from .py_versions import OLDEST_SUPPORTED_MINOR
|
||
|
|
|
||
|
|
|
||
|
|
class Builder(AbstractBuilder):
|
||
|
|
""" The default base implementation of a project builder. """
|
||
|
|
|
||
|
|
def build(self):
|
||
|
|
""" Build the project in-situ. """
|
||
|
|
|
||
|
|
self._generate_bindings()
|
||
|
|
self._generate_scripts()
|
||
|
|
|
||
|
|
if self.project.compile:
|
||
|
|
self.build_project(self.project.target_dir)
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
def build_executable(self, buildable, *, fatal=True):
|
||
|
|
""" Build an executable from a BuildableExecutable object and return
|
||
|
|
the relative pathname of the executable.
|
||
|
|
"""
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
def build_project(self, target_dir, *, wheel_tag=None):
|
||
|
|
""" Build the project. """
|
||
|
|
|
||
|
|
def build_sdist(self, sdist_directory):
|
||
|
|
""" Build an sdist for the project and return the name of the sdist
|
||
|
|
file.
|
||
|
|
"""
|
||
|
|
|
||
|
|
project = self.project
|
||
|
|
|
||
|
|
# The sdist name.
|
||
|
|
sdist_name = '{}-{}'.format(project.name.replace('-', '_').lower(),
|
||
|
|
project.version_str)
|
||
|
|
|
||
|
|
# Create the sdist root directory.
|
||
|
|
sdist_root = os.path.join(project.build_dir, sdist_name)
|
||
|
|
os.mkdir(sdist_root)
|
||
|
|
|
||
|
|
# Get a list of all excluded files.
|
||
|
|
excluded = []
|
||
|
|
for patt in project.sdist_excludes:
|
||
|
|
excluded.extend(glob.glob(os.path.join(project.root_dir, patt)))
|
||
|
|
|
||
|
|
for dname, dirnames, filenames in os.walk(project.root_dir):
|
||
|
|
# Always ignore certain directories.
|
||
|
|
if dname == project.build_dir:
|
||
|
|
del dirnames[:]
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
dirnames.remove('__pycache__')
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Copy each file non-excluded file.
|
||
|
|
for s_fn in filenames:
|
||
|
|
s_fn_path = os.path.join(dname, s_fn)
|
||
|
|
|
||
|
|
if s_fn_path in excluded:
|
||
|
|
continue
|
||
|
|
|
||
|
|
d_fn_path = os.path.join(sdist_root,
|
||
|
|
os.path.relpath(s_fn_path, project.root_dir))
|
||
|
|
os.makedirs(os.path.dirname(d_fn_path), exist_ok=True)
|
||
|
|
|
||
|
|
shutil.copy2(s_fn_path, d_fn_path)
|
||
|
|
|
||
|
|
# Create the PKG-INFO file. This is assumed to be identical to the
|
||
|
|
# .dist-info/METADATA file.
|
||
|
|
write_metadata(project.metadata, project.get_requires_dists(),
|
||
|
|
os.path.join(sdist_root, 'PKG-INFO'), project.root_dir)
|
||
|
|
|
||
|
|
# Create the tarball.
|
||
|
|
sdist_file = sdist_name + '.tar.gz'
|
||
|
|
sdist_path = os.path.abspath(os.path.join(sdist_directory, sdist_file))
|
||
|
|
|
||
|
|
saved_cwd = os.getcwd()
|
||
|
|
os.chdir(project.build_dir)
|
||
|
|
|
||
|
|
import tarfile
|
||
|
|
|
||
|
|
tf = tarfile.open(sdist_path, 'w:gz', format=tarfile.PAX_FORMAT)
|
||
|
|
tf.add(sdist_name)
|
||
|
|
tf.close()
|
||
|
|
|
||
|
|
os.chdir(saved_cwd)
|
||
|
|
|
||
|
|
return sdist_file
|
||
|
|
|
||
|
|
def build_wheel(self, wheel_directory):
|
||
|
|
""" Build a wheel for the project and return the name of the wheel
|
||
|
|
file.
|
||
|
|
"""
|
||
|
|
|
||
|
|
project = self.project
|
||
|
|
|
||
|
|
# Create a temporary directory for the wheel.
|
||
|
|
wheel_build_dir = os.path.join(project.build_dir, 'wheel')
|
||
|
|
os.mkdir(wheel_build_dir)
|
||
|
|
|
||
|
|
# Build the wheel contents.
|
||
|
|
self._generate_bindings()
|
||
|
|
|
||
|
|
# Create the wheel tag. If all buildables use the limited API then the
|
||
|
|
# wheel does.
|
||
|
|
wheel_tag = []
|
||
|
|
|
||
|
|
if project.all_modules_use_limited_abi:
|
||
|
|
# When the ABI tag is 'abi3' the interpreter tag is interpreted as
|
||
|
|
# a minimum Python version. This doesn't seem to be defined in a
|
||
|
|
# PEP but is implemented in current pips.
|
||
|
|
wheel_tag.append('cp3' + str(OLDEST_SUPPORTED_MINOR))
|
||
|
|
wheel_tag.append('abi3')
|
||
|
|
else:
|
||
|
|
major_minor = '{}{}'.format((sys.hexversion >> 24) & 0xff,
|
||
|
|
(sys.hexversion >> 16) & 0xff)
|
||
|
|
|
||
|
|
wheel_tag.append('cp{}'.format(major_minor))
|
||
|
|
|
||
|
|
try:
|
||
|
|
wheel_tag.append('cp' + major_minor + sys.abiflags)
|
||
|
|
except AttributeError:
|
||
|
|
wheel_tag.append('none')
|
||
|
|
|
||
|
|
wheel_tag.append(project.get_platform_tag())
|
||
|
|
|
||
|
|
wheel_tag = '-'.join(wheel_tag)
|
||
|
|
|
||
|
|
# Add any wheel contents defined by the project.
|
||
|
|
for nr, (patt, target_subdir) in enumerate(project.wheel_includes):
|
||
|
|
if not os.path.isabs(patt):
|
||
|
|
patt = os.path.join(project.root_dir, patt)
|
||
|
|
|
||
|
|
wheel_includes = glob.glob(patt)
|
||
|
|
if wheel_includes:
|
||
|
|
installable = Installable(
|
||
|
|
'wheel_includes_{}'.format(nr) if nr else 'wheel_includes',
|
||
|
|
target_subdir=target_subdir)
|
||
|
|
installable.files.extend(wheel_includes)
|
||
|
|
project.installables.append(installable)
|
||
|
|
|
||
|
|
# Build the project.
|
||
|
|
self.build_project(wheel_build_dir, wheel_tag=wheel_tag)
|
||
|
|
|
||
|
|
# Copy the wheel contents.
|
||
|
|
self.install_project(wheel_build_dir, wheel_tag=wheel_tag)
|
||
|
|
|
||
|
|
wheel_file = '{}-{}'.format(project.name.replace('-', '_'),
|
||
|
|
project.version_str)
|
||
|
|
|
||
|
|
if project.build_tag:
|
||
|
|
wheel_file += '-{}'.format(project.build_tag)
|
||
|
|
|
||
|
|
wheel_file += '-{}.whl'.format(wheel_tag)
|
||
|
|
wheel_path = os.path.abspath(os.path.join(wheel_directory, wheel_file))
|
||
|
|
|
||
|
|
# Create the .whl file.
|
||
|
|
saved_cwd = os.getcwd()
|
||
|
|
os.chdir(wheel_build_dir)
|
||
|
|
|
||
|
|
from zipfile import ZipFile, ZIP_DEFLATED
|
||
|
|
|
||
|
|
with ZipFile(wheel_path, 'w', compression=ZIP_DEFLATED) as zf:
|
||
|
|
for dirpath, _, filenames in os.walk('.'):
|
||
|
|
for filename in filenames:
|
||
|
|
# This will result in a name with no leading '.'.
|
||
|
|
name = os.path.relpath(os.path.join(dirpath, filename))
|
||
|
|
|
||
|
|
zf.write(name)
|
||
|
|
|
||
|
|
os.chdir(saved_cwd)
|
||
|
|
|
||
|
|
return wheel_file
|
||
|
|
|
||
|
|
def install(self):
|
||
|
|
""" Install the project. """
|
||
|
|
|
||
|
|
target_dir = self.project.target_dir
|
||
|
|
|
||
|
|
self._generate_bindings()
|
||
|
|
self._generate_scripts()
|
||
|
|
self.build_project(target_dir)
|
||
|
|
self.install_project(target_dir)
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
def install_project(self, target_dir, *, wheel_tag=None):
|
||
|
|
""" Install the project into a target directory. """
|
||
|
|
|
||
|
|
def _generate_bindings(self):
|
||
|
|
""" Generate the bindings for all enabled modules. """
|
||
|
|
|
||
|
|
project = self.project
|
||
|
|
|
||
|
|
# Get the list of directories to search for .sip files.
|
||
|
|
sip_include_dirs = list(project.sip_include_dirs)
|
||
|
|
|
||
|
|
if project.sip_module:
|
||
|
|
# Add the project's sip directory.
|
||
|
|
sip_include_dirs.append(project.sip_files_dir)
|
||
|
|
|
||
|
|
# Add the local bindings directory to pick up the .toml files for
|
||
|
|
# any other bindings in this package.
|
||
|
|
local_bindings_dir = os.path.join(project.build_dir, 'bindings')
|
||
|
|
sip_include_dirs.append(local_bindings_dir)
|
||
|
|
|
||
|
|
# Add any bindings from previously installed packages.
|
||
|
|
sip_include_dirs.append(
|
||
|
|
os.path.join(project.target_dir,
|
||
|
|
project.get_bindings_dir()))
|
||
|
|
|
||
|
|
# Generate the code for each set of bindings.
|
||
|
|
api_files = []
|
||
|
|
|
||
|
|
for bindings in project.bindings.values():
|
||
|
|
project.progress(
|
||
|
|
"Generating the {0} bindings".format(bindings.name))
|
||
|
|
|
||
|
|
# Generate the source code. We would prefer to pass the include
|
||
|
|
# directories as an argument to generate but this is a fixed public
|
||
|
|
# interface.
|
||
|
|
bindings._sip_include_dirs = sip_include_dirs
|
||
|
|
buildable = bindings.generate()
|
||
|
|
del bindings._sip_include_dirs
|
||
|
|
|
||
|
|
if not bindings.internal:
|
||
|
|
api_files.append(
|
||
|
|
os.path.join(project.build_dir,
|
||
|
|
buildable.target + '.api'))
|
||
|
|
|
||
|
|
# Generate the bindings configuration file.
|
||
|
|
if project.sip_module:
|
||
|
|
buildable.write_configuration(local_bindings_dir)
|
||
|
|
|
||
|
|
project.buildables.append(buildable)
|
||
|
|
|
||
|
|
if project.sip_module:
|
||
|
|
# Generate the sip.h file for the shared sip module now that we
|
||
|
|
# know the target ABI..
|
||
|
|
copy_sip_h(project.build_abi, project.build_dir,
|
||
|
|
project.sip_module, version_info=project.version_info)
|
||
|
|
|
||
|
|
# Create __init__.py if required.
|
||
|
|
if project.dunder_init:
|
||
|
|
package_dir = project.get_package_dir()
|
||
|
|
|
||
|
|
init_path = os.path.join(project.build_dir, '__init__.py')
|
||
|
|
|
||
|
|
init_f = project.open_for_writing(init_path)
|
||
|
|
init_f.write(project.get_dunder_init())
|
||
|
|
init_f.close()
|
||
|
|
|
||
|
|
installable = Installable('init', target_subdir=package_dir)
|
||
|
|
installable.files.append(init_path)
|
||
|
|
project.installables.append(installable)
|
||
|
|
|
||
|
|
# Include sip.pyi if any of the bindings generate a .pyi file.
|
||
|
|
for bindings in project.bindings.values():
|
||
|
|
if bindings.pep484_pyi:
|
||
|
|
copy_sip_pyi(project.build_abi, project.build_dir)
|
||
|
|
|
||
|
|
installable = Installable('sip_pyi',
|
||
|
|
target_subdir=package_dir)
|
||
|
|
installable.files.append(
|
||
|
|
os.path.join(project.build_dir, 'sip.pyi'))
|
||
|
|
project.installables.append(installable)
|
||
|
|
|
||
|
|
# Create a PEP 561 marker file.
|
||
|
|
py_typed_path = os.path.join(project.build_dir, 'py.typed')
|
||
|
|
with open(py_typed_path, 'w') as _:
|
||
|
|
pass
|
||
|
|
|
||
|
|
installable = Installable('py_typed',
|
||
|
|
target_subdir=package_dir)
|
||
|
|
installable.files.append(py_typed_path)
|
||
|
|
project.installables.append(installable)
|
||
|
|
|
||
|
|
break
|
||
|
|
|
||
|
|
# Create the .api file if required.
|
||
|
|
if project.api_dir:
|
||
|
|
api_fn = project.name + '.api'
|
||
|
|
|
||
|
|
project.progress("Generating the {0} file".format(api_fn))
|
||
|
|
|
||
|
|
# Concatanate the individual .api files.
|
||
|
|
api_path = os.path.join(project.build_dir, api_fn)
|
||
|
|
api_f = project.open_for_writing(api_path)
|
||
|
|
|
||
|
|
for part_fn in api_files:
|
||
|
|
with open(part_fn) as part_f:
|
||
|
|
api_f.write(part_f.read())
|
||
|
|
|
||
|
|
api_f.close()
|
||
|
|
|
||
|
|
# Add an Installable for the requested API file.
|
||
|
|
installable = Installable('api', target_subdir=project.api_dir)
|
||
|
|
installable.files.append(api_path)
|
||
|
|
project.installables.append(installable)
|
||
|
|
|
||
|
|
def _generate_scripts(self):
|
||
|
|
""" Generate the scripts for any entry points. """
|
||
|
|
|
||
|
|
project = self.project
|
||
|
|
|
||
|
|
# Handle the trivial case.
|
||
|
|
scripts = project.console_scripts + project.gui_scripts
|
||
|
|
if not scripts:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Create an installable for the scripts.
|
||
|
|
installable = Installable('scripts', target_subdir=project.scripts_dir)
|
||
|
|
|
||
|
|
for ep in scripts:
|
||
|
|
# Parse the entry point.
|
||
|
|
ep_parts = ep.replace(' ', '').split('=')
|
||
|
|
|
||
|
|
if len(ep_parts) != 2:
|
||
|
|
raise UserException(
|
||
|
|
"'{0}' is an invalid script specification".format(ep))
|
||
|
|
|
||
|
|
script, module = ep_parts
|
||
|
|
|
||
|
|
# Remove any callable name.
|
||
|
|
module = module.split(':')[0]
|
||
|
|
|
||
|
|
if project.py_platform == 'win32':
|
||
|
|
script += '.bat'
|
||
|
|
|
||
|
|
project.progress("Generating the {} script".format(script))
|
||
|
|
|
||
|
|
script_path = os.path.join(project.build_dir, script)
|
||
|
|
installable.files.append(script_path)
|
||
|
|
|
||
|
|
script_f = project.open_for_writing(script_path)
|
||
|
|
|
||
|
|
if project.py_platform == 'win32':
|
||
|
|
script_f.write(
|
||
|
|
'@{} -m {} %1 %2 %3 %4 %5 %6 %7 %8 %9\n'.format(
|
||
|
|
sys.executable, module))
|
||
|
|
else:
|
||
|
|
script_f.write('#!/bin/sh\n')
|
||
|
|
script_f.write(
|
||
|
|
'exec %s -m %s ${1+"$@"}\n' % (sys.executable,
|
||
|
|
module))
|
||
|
|
|
||
|
|
script_f.close()
|
||
|
|
|
||
|
|
# Make the script executable.
|
||
|
|
os.chmod(script_path,
|
||
|
|
stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR|
|
||
|
|
stat.S_IRGRP|stat.S_IXGRP|
|
||
|
|
stat.S_IROTH|stat.S_IXOTH)
|
||
|
|
|
||
|
|
project.installables.append(installable)
|