build: write a bare minimal PEP 517 build backend

Drop our dependency on setuptools and write our own PEP 517 build
backend.  This turns out to be relatively straightforward — only ~100
lines, and avoids the workarounds we had to do to prevent setuptools
from trying to write to the source tree.  It's also dramatically
faster.

While we're at it, we can now fix a long-standing nag: we now have a
proper place to make sure that `modules/checkout` gets called before
building an sdist or a wheel.  That means that you can run `tox` or `pip
install .` in a freshly checked-out tree and expect correct results.

We have to add a command-line interface to support wheel creation on
RHEL8, where pip is not yet PEP517-aware.

We don't implement any optional features of PEP 517 and we also don't
support editable installs.
This commit is contained in:
Allison Karlitskaya 2023-05-11 23:13:37 +02:00
parent 2cac96bc19
commit 10de075181
8 changed files with 129 additions and 75 deletions

View File

@ -47,7 +47,7 @@ distdir: $(DISTFILES)
# Needed to ensure the tarball is correct for $(VERSION) override
dist-hook: $(distdir)/src/cockpit/_version.py
$(distdir)/src/cockpit/_version.py: FORCE
$(srcdir)/tools/dist-setuptools '$(srcdir)' '$(distdir)'
python3 '$(srcdir)'/src/build_backend.py --copy '$(srcdir)' '$(distdir)'
@rm -f $(distdir)/src/cockpit/_version.py
$(AM_V_GEN) echo "__version__ = '$(VERSION)'" > $@

View File

@ -6,7 +6,6 @@ srcdir="${0%/*}"
(
cd "${srcdir}"
modules/checkout
echo "m4_define(VERSION_NUMBER, [$(git describe --tags --abbrev=0)+git])" > version.m4
autoreconf -i --warnings obsolete
)

View File

@ -1,6 +1,7 @@
[build-system]
requires = ["setuptools >= 39.2.0"]
build-backend = "setuptools.build_meta"
requires = []
backend-path = ['src']
build-backend = 'build_backend'
[tool.mypy]
mypy_path = 'src/cockpit'

View File

@ -1,21 +0,0 @@
[metadata]
name = cockpit
version = attr: cockpit.__version__
[options]
packages = find:
package_dir =
=src
[options.packages.find]
where = src
include = cockpit*
[options.entry_points]
console_scripts =
cockpit-bridge = cockpit.bridge:main
cockpit-askpass = cockpit._vendor.ferny.interaction_client:main
[options.package_data]
cockpit.data =
*.html

View File

@ -26,10 +26,8 @@ install-python:
@# wheel-based installation with .dist-info.
@# This needs to work on RHEL8 up through modern Fedora, offline, with
@# system packages available to the build.
@rm -rf tmp/pybuild
'$(srcdir)'/tools/dist-setuptools '$(srcdir)' tmp/pybuild
cd tmp/pybuild && python3 -c 'from setuptools import setup; setup()' bdist_wheel
python3 -m pip install --no-index --force-reinstall --root='$(DESTDIR)/' --prefix='$(prefix)' tmp/pybuild/dist/*.whl
python3 -m pip install --no-index --force-reinstall --root='$(DESTDIR)/' --prefix='$(prefix)' \
"$$(python3 '$(srcdir)'/src/build_backend.py --wheel '$(srcdir)' tmp/wheel)"
mkdir -p $(DESTDIR)$(libexecdir)
mv -t $(DESTDIR)$(libexecdir) $(DESTDIR)$(bindir)/cockpit-askpass
endif

123
src/build_backend.py Normal file
View File

@ -0,0 +1,123 @@
import argparse
import base64
import hashlib
import os
import shutil
import subprocess
import tarfile
import zipfile
from typing import Dict, Iterable, Optional
from cockpit import __version__
PACKAGE = f'cockpit-{__version__}'
TAG = 'py3-none-any'
def find_sources(srcpkg: bool) -> Iterable[str]:
try:
subprocess.check_call(['modules/checkout'], stdout=2) # Needed for git builds...
except FileNotFoundError: # ...but not present in tarball...
pass # ...and not needed either, because...
assert os.path.exists('src/cockpit/_vendor/ferny/__init__.py') # ...the code should exist there already.
if srcpkg:
yield from {
'pyproject.toml',
'src/build_backend.py',
}
for path, _dirs, files in os.walk('src', followlinks=True):
if '__init__.py' in files:
yield from [os.path.join(path, file) for file in files]
def copy_sources(distdir: str) -> None:
for source in find_sources(srcpkg=True):
destination = os.path.join(distdir, source)
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copy(source, destination)
def build_sdist(sdist_directory: str,
config_settings: Optional[Dict[str, object]] = None) -> str:
del config_settings
sdist_filename = f'{PACKAGE}.tar.gz'
with tarfile.open(f'{sdist_directory}/{sdist_filename}', 'w:gz', dereference=True) as sdist:
for filename in find_sources(srcpkg=True):
sdist.add(filename, arcname=f'{PACKAGE}/{filename}', )
return sdist_filename
def build_wheel(wheel_directory: str,
config_settings: Optional[Dict[str, object]] = None,
metadata_directory: Optional[str] = None) -> str:
del config_settings, metadata_directory
wheel_filename = f'{PACKAGE}-{TAG}.whl'
distinfo = {
'WHEEL': [
'Wheel-Version: 1.0',
'Generator: cockpit build_backend',
'Root-Is-Purelib: true',
f'Tag: {TAG}',
],
'METADATA': [
'Metadata-Version: 2.1',
'Name: cockpit',
f'Version: {__version__}',
],
'entry_points.txt': [
'[console_scripts]',
'cockpit-bridge = cockpit.bridge:main',
'cockpit-askpass = cockpit._vendor.ferny.interaction_client:main',
],
}
with zipfile.ZipFile(f'{wheel_directory}/{wheel_filename}', 'w') as wheel:
def write_distinfo(filename: str, lines: Iterable[str]) -> None:
wheel.writestr(f'{PACKAGE}.dist-info/{filename}', ''.join(f'{line}\n' for line in lines))
def record_lines() -> Iterable[str]:
for info in wheel.infolist():
digest = hashlib.sha256(wheel.read(info.filename)).digest()
b64_digest = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
yield f'{info.filename},sha256={b64_digest},{info.file_size}'
yield f'{PACKAGE}.dist-info/RECORD,,'
for filename in find_sources(srcpkg=False):
wheel.write(filename, arcname=os.path.relpath(filename, start='src'))
for filename, lines in distinfo.items():
write_distinfo(filename, lines)
write_distinfo('RECORD', record_lines())
return wheel_filename
def main() -> None:
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--copy', action='store_true')
group.add_argument('--sdist', action='store_true')
group.add_argument('--wheel', action='store_true')
parser.add_argument('srcdir')
parser.add_argument('destdir')
args = parser.parse_args()
# We have to chdir() for PEP 517, so make sure dest is absolute
destdir = os.path.abspath(args.destdir)
os.chdir(args.srcdir)
os.makedirs(destdir, exist_ok=True)
if args.copy:
copy_sources(destdir)
elif args.sdist:
print(os.path.join(destdir, build_sdist(destdir)))
else:
print(os.path.join(destdir, build_wheel(destdir)))
if __name__ == '__main__':
main()

View File

@ -170,8 +170,6 @@ Requires: subscription-manager-cockpit
%if %{cockpit_enable_python}
BuildRequires: python3-devel
BuildRequires: python3-pip
BuildRequires: python3-setuptools
BuildRequires: python3-wheel
%if 0%{?rhel} == 0
# All of these are only required for running pytest (which we only do on Fedora)
BuildRequires: procps-ng

View File

@ -1,44 +0,0 @@
#!/usr/bin/python3
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('srcdir')
parser.add_argument('distdir')
args = parser.parse_args()
# setuptools can't create an sdist without writing (at least) an .egg-info
# directory to the source directory. That conflicts with automake's
# desire that `make dist` should not modify the source directory.
#
# As a hack: we use the setuptools egg_info command (with its output
# redirected to /tmp) to get a list of the files that we need to ship, and
# copy those over manually.
with tempfile.TemporaryDirectory() as tmpdir:
subprocess.check_call([sys.executable, '-c', 'from setuptools import setup; setup();',
'--quiet', 'egg_info', '--egg-base', tmpdir], cwd=args.srcdir)
# Collect the result
# We need to filter out the /tmp/xyz/src/cockpit.egg-info bits,
# which we can do by only taking the relative pathnames.
with open(f'{tmpdir}/cockpit.egg-info/SOURCES.txt') as file:
distfiles = [line.strip() for line in file if not os.path.isabs(line)]
# Copy the required source files into the named destination directory
for filename in distfiles:
source = f'{args.srcdir}/{filename}'
destination = f'{args.distdir}/{filename}'
if not os.path.exists(destination): # avoid re-copying e.g. AUTHORS
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copy(source, destination)
if __name__ == '__main__':
main()