Files

374 lines
13 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
# 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)