From 6fbadf153dbe0fe217915e9293b08e400a3c91d8 Mon Sep 17 00:00:00 2001 From: Stewart Miles Date: Mon, 7 Oct 2024 07:04:37 -0700 Subject: [PATCH] Add wheel with a C extension to test mounting (#229) This recreates the source to the `tests/minimext*.whl` wheels with a couple of differences: * The C extension is placed in the minimext package rather than being a top level package. * A Python implementation of the C extension is provided to make it easier for pure Python programmers to understand / modify. This also adds a script `build_wheels.sh` that will install Python interpreters and build the minimext wheel for different Python versions with and without adding the `EXTENSIONS` metadata file. Related to #222 --- .gitignore | 5 + tests/minimext/LICENSE | 1 + tests/minimext/README.txt | 1 + tests/minimext/build_wheels.sh | 117 ++++++++++++++++++++++++ tests/minimext/calculate.c | 88 ++++++++++++++++++ tests/minimext/minimext/__init__.py | 0 tests/minimext/minimext/calculate_py.py | 25 +++++ tests/minimext/setup.py | 67 ++++++++++++++ 8 files changed, 304 insertions(+) create mode 100644 tests/minimext/LICENSE create mode 100644 tests/minimext/README.txt create mode 100755 tests/minimext/build_wheels.sh create mode 100644 tests/minimext/calculate.c create mode 100644 tests/minimext/minimext/__init__.py create mode 100644 tests/minimext/minimext/calculate_py.py create mode 100644 tests/minimext/setup.py diff --git a/.gitignore b/.gitignore index 1d002b1d..1cb106d6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ distlib-vcsid tests/passwords tests/keys tests/pypi-server-standalone.py +tests/minimext/*.egg-info +tests/minimext/.venv* +tests/minimext/minimext/*.pyd +tests/minimext/minimext/*.so +tests/minimext/wheels dist htmlcov build diff --git a/tests/minimext/LICENSE b/tests/minimext/LICENSE new file mode 100644 index 00000000..0224b536 --- /dev/null +++ b/tests/minimext/LICENSE @@ -0,0 +1 @@ +BSD-licensed. diff --git a/tests/minimext/README.txt b/tests/minimext/README.txt new file mode 100644 index 00000000..d144c022 --- /dev/null +++ b/tests/minimext/README.txt @@ -0,0 +1 @@ +Distribution with a simple C extension that calculates Fibonacci numbers. diff --git a/tests/minimext/build_wheels.sh b/tests/minimext/build_wheels.sh new file mode 100755 index 00000000..6c400338 --- /dev/null +++ b/tests/minimext/build_wheels.sh @@ -0,0 +1,117 @@ +#!/bin/bash -e +# Copyright (C) 2024 Stewart Miles +# Licensed to the Python Software Foundation under a contributor agreement. +# See LICENSE.txt and CONTRIBUTORS.txt. + +readonly DEFAULT_PYTHON_VERSION="$(python --version | + cut -d ' ' -f 2 | + cut -d. -f 1,2)" +readonly DEFAULT_PYTHON_VERSIONS="2.7 3.5 ${DEFAULT_PYTHON_VERSION}" + + +help() { + echo "\ +Builds Linux wheels for this package using a range of Python distributions. + +This script requires a Ubuntu distribution and will leave the deadsnakes PPA +https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa +and Python packages used by this script installed. + +$(basename "$0") [-d] [-I] [-S] [-p versions] [-h] + +-d: Enable dry run mode, simply display rather than execute commands. +-S: Disable Python PPA installation. +-I: Disable Python apt package installation +-p: Space separated list of Python versions to install and build wheels for. + This defaults to \"${DEFAULT_PYTHON_VERSIONS}\". +-h: Display this help. +" + exit 1 +} + +main() { + readonly THIS_DIRECTORY="$(cd "$(dirname "${0}")"; pwd)" + local dryrun= + local install_python=1 + local install_python_ppa=1 + local selected_python_versions="${DEFAULT_PYTHON_VERSIONS}" + + while getopts "dhISp:" OPTION; do + # shellcheck disable=SC2209 + case "${OPTION}" in + d) dryrun=echo + ;; + I) install_python=0 + ;; + S) install_python_ppa=0 + ;; + p) selected_python_versions="${OPTARG}" + ;; + h|*) help + ;; + esac + done + + IFS=' ' read -r -a python_versions <<< "${selected_python_versions}" + + if [[ $((install_python_ppa)) -eq 1 ]]; then + set -x + ${dryrun} sudo add-apt-repository ppa:deadsnakes/ppa + set +x + fi + + if [[ $((install_python)) -eq 1 ]]; then + # shellcheck disable=SC2207 + readonly -a PYTHON_APT_PACKAGES=( + $(for version in "${python_versions[@]}"; do + echo "python${version}-dev"; + done)) + set -x + ${dryrun} sudo apt install "${PYTHON_APT_PACKAGES[@]}" + set +x + fi + + local wheels_directory="${THIS_DIRECTORY}/wheels" + mkdir -p "${wheels_directory}" + + local venv_directory + local versioned_python + local version + for version in "${python_versions[@]}"; do + versioned_python="python${version}" + venv_directory="${THIS_DIRECTORY}/.venv${version}" + + # Try to bootstrap pip if it isn't found. + if ! ${dryrun} "${versioned_python}" -c "import pip" 2> /dev/null; then + # shellcheck disable=SC2155 + local temporary_directory="$(mktemp -d)" + local get_pip="${temporary_directory}/get-pip-${version}.py" + ${dryrun} curl --output "${get_pip}" \ + "https://bootstrap.pypa.io/pip/${version}/get-pip.py" + ${dryrun} "${versioned_python}" "${get_pip}" + rm -rf "${temporary_directory}" + fi + + # Install virtualenv as venv isn't available in all Python versions. + ${dryrun} "${versioned_python}" -m pip install virtualenv + ${dryrun} "${versioned_python}" -m virtualenv "${venv_directory}" + ( + cd "${THIS_DIRECTORY}" + ${dryrun} source "${venv_directory}/bin/activate" + # Upgrade pip and setuptools. + ${dryrun} pip install -U pip + ${dryrun} pip install -U setuptools + # Build wheels to the wheels subdirectory. + for embed_extension_metadata in 0 1; do + set -x + MINIMEXT_EMBED_EXTENSIONS_METADATA=${embed_extension_metadata} \ + ${dryrun} pip wheel . -w "${wheels_directory}" + set +x + done + ) + done + + cp "${wheels_directory}"/*.whl "${THIS_DIRECTORY}/.." +} + +main "$@" diff --git a/tests/minimext/calculate.c b/tests/minimext/calculate.c new file mode 100644 index 00000000..2b0fb3a1 --- /dev/null +++ b/tests/minimext/calculate.c @@ -0,0 +1,88 @@ +// Copyright (C) 2024 Stewart Miles +// Licensed to the Python Software Foundation under a contributor agreement. +// See LICENSE.txt and CONTRIBUTORS.txt. + +// Use the limited API to ensure ABI compatibility across all major Python +// versions starting from 3.2 +// https://docs.python.org/3/c-api/stable.html#limited-c-api +#if !defined(Py_LIMITED_API) +#define Py_LIMITED_API 3 +#endif // !defined(Py_LIMITED_API) +#define PY_SSIZE_T_CLEAN +#include + +// Name and doc string for this module. +#define MODULE_NAME calculate +#define MODULE_DOCS "Calculates Fibonacci numbers." + +// Convert the argument into a string. +#define _STRINGIFY(x) #x +#define STRINGIFY(x) _STRINGIFY(x) + +// Calculate a Fibonacci number at the specified index of the sequence. +static PyObject *fib(PyObject *self, PyObject *args) +{ + long int index; + if (!PyArg_ParseTuple(args, "l", &index)) { + PyErr_SetString(PyExc_ValueError, "An index must be specified."); + } + + long int current_value = 1; + long int previous_value = 0; + index--; + for ( ; index > 0 ; --index) { + long int next_value = current_value + previous_value; + previous_value = current_value; + current_value = next_value; + } + return PyLong_FromLong(current_value); +} + +// Exposes methods in this module. +static PyMethodDef methods[] = +{ + { + "fib", + fib, + METH_VARARGS, + PyDoc_STR("Calculate a Fibonacci number.\n" + "\n" + ":param index: Index of the number in the Fibonacci sequence\n" + " to calculate.\n" + "\n" + ":returns: Fibonacci number at the specified index.\n" + " For example an index of 7 will return 13\n"), + }, +}; + +#if PY_MAJOR_VERSION >= 3 +// Defines the module. +static struct PyModuleDef module = +{ + PyModuleDef_HEAD_INIT, + STRINGIFY(MODULE_NAME), + PyDoc_STR(MODULE_DOCS), + -1, + methods, +}; +#endif // PY_MAJOR_VERSION >= 3 + +// Expands to the init function name. +#define _PYINIT_FUNCTION_NAME(prefix, name) prefix ## name +#define PYINIT_FUNCTION_NAME(prefix, name) _PYINIT_FUNCTION_NAME(prefix, name) + +// Initialize this module. +#if PY_MAJOR_VERSION >= 3 +PyMODINIT_FUNC +PYINIT_FUNCTION_NAME(PyInit_, MODULE_NAME)(void) +{ + return PyModule_Create(&module); +} +#else +PyMODINIT_FUNC +PYINIT_FUNCTION_NAME(init, MODULE_NAME)(void) +{ + // Ignore the returned module object. + (void)Py_InitModule(STRINGIFY(MODULE_NAME), methods); +} +#endif // PY_MAJOR_VERSION >= 3 diff --git a/tests/minimext/minimext/__init__.py b/tests/minimext/minimext/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/minimext/minimext/calculate_py.py b/tests/minimext/minimext/calculate_py.py new file mode 100644 index 00000000..67d98b7f --- /dev/null +++ b/tests/minimext/minimext/calculate_py.py @@ -0,0 +1,25 @@ +# Copyright (C) 2024 Stewart Miles +# Licensed to the Python Software Foundation under a contributor agreement. +# See LICENSE.txt and CONTRIBUTORS.txt. + +"""Python implementation of the calculate extension module.""" + +def fib(index): + """Calculate a Fibonacci number. + + :param index: Index of the number in the Fibonacci sequence + to calculate. + + :returns: Fibonacci number at the specified index. + For example an index of 7 will return 13 + """ + current_value = 1 + previous_value = 0 + index -= 1 + while index > 0: + next_value = current_value + previous_value + previous_value = current_value + current_value = next_value + index -= 1 + return current_value + diff --git a/tests/minimext/setup.py b/tests/minimext/setup.py new file mode 100644 index 00000000..fd2616ea --- /dev/null +++ b/tests/minimext/setup.py @@ -0,0 +1,67 @@ +# Copyright (C) 2024 Stewart Miles +# Licensed to the Python Software Foundation under a contributor agreement. +# See LICENSE.txt and CONTRIBUTORS.txt. +import codecs +import os +import json +from setuptools import Extension, setup +from setuptools.command import egg_info +import sys + + +EMBED_EXTENSIONS_METADATA = ( + int(os.getenv('MINIMEXT_EMBED_EXTENSIONS_METADATA', '0'))) + + +class EggInfo(egg_info.egg_info): + """egg_info command that optionally writes extensions metadata. + + distlib.wheel.Wheel attempts to read the list of extensions from the + undocumented JSON EXTENSIONS metadata file. + + This command will add the special file JSON EXTENSIONS metadata file to the + *.dist-info directory in the wheel if the + MINIMEXT_EMBED_EXTENSIONS_METADATA environment variable is set to 1. + """ + + def run(self): + egg_info.egg_info.run(self) + if EMBED_EXTENSIONS_METADATA: + build_ext = self.get_finalized_command('build_ext') + extensions_dict = { + ext_module.name: build_ext.get_ext_filename(ext_module.name) + for ext_module in self.distribution.ext_modules + } + with open(os.path.join(self.egg_info, 'EXTENSIONS'), 'wb') as ( + extensions_file): + json.dump(extensions_dict, + codecs.getwriter('utf-8')(extensions_file), + indent=2) + + +setup( + name='minimext' + ('_metadata' if EMBED_EXTENSIONS_METADATA else ''), + version='0.1', + description='Calculates Fibonacci numbers.', + long_description=( + 'Distribution that provides calculate.fib() and calculate_py.fib() ' + 'which calculate Fibonacci numbers. minimext.calculate is implemented ' + 'as a C extension to test distlib.wheel.Wheel.mount().'), + packages=['minimext'], + ext_modules=[ + Extension(name='minimext.calculate', + sources=['calculate.c'], + py_limited_api=True, + define_macros=[ + ('Py_LIMITED_API', str(sys.version_info.major)), + ]), + ], + # The extension uses the limited API so tag the wheel as compatible with + # Python 3.2 and later. + # + # Unfortunately the py_limited_api argument to Extension does not mark the + # wheel as supporting the limited API, so set the see compatibility + # manually. + options={'bdist_wheel': {'py_limited_api': 'cp32'}}, + cmdclass={'egg_info': EggInfo}, +)