"""Implements the 'bdist_rpm' command (create RPM binary distributions). Borrowed from distutils.command.bdist_rpm of Python 3.10 and merged with bdist_rpm subclass of cx_Freeze 6.10. https://rpm.org/documentation.html https://rpm-packaging-guide.github.io/ """ from __future__ import annotations import logging import os import platform import shutil import sys import tarfile from subprocess import CalledProcessError, check_output from sysconfig import get_python_version from typing import ClassVar from setuptools import Command from cx_Freeze._compat import IS_CONDA from cx_Freeze.exception import ExecError, FileError, PlatformError __all__ = ["bdist_rpm"] class bdist_rpm(Command): """Create an RPM distribution.""" description = "create an RPM distribution" user_options: ClassVar[list[tuple[str, str | None, str]]] = [ ( "bdist-base=", None, "base directory for creating built distributions", ), ( "rpm-base=", None, "base directory for creating RPMs " '[defaults to "rpm" under "--bdist-base"]', ), ( "dist-dir=", "d", "directory to put final RPM files in " "(and .spec files if --spec-only)", ), ("spec-only", None, "only regenerate spec file"), # More meta-data: too RPM-specific to put in the setup script, # but needs to go in the .spec file -- so we make these options # to "bdist_rpm". The idea is that packagers would put this # info in pyproject.toml or setup.cfg, although they are of course free # to supply it on the command line. ( "distribution-name=", None, "name of the (Linux) distribution to which this " "RPM applies (*not* the name of the module distribution!)", ), ( "group=", None, 'package classification [default: "Development/Libraries"]', ), ("release=", None, "RPM release number"), ("serial=", None, "RPM serial number"), ( "vendor=", None, 'RPM "vendor" (eg. "Joe Blow ") ' "[default: maintainer or author from setup script]", ), ( "packager=", None, 'RPM packager (eg. "Jane Doe ") ' "[default: same as vendor]", ), ( "doc-files=", None, "list of documentation files (space or comma-separated)", ), ("changelog=", None, "RPM changelog"), ("icon=", None, "name of icon file"), ("provides=", None, "capabilities provided by this package"), ("requires=", None, "capabilities required by this package"), ("conflicts=", None, "capabilities which conflict with this package"), ( "build-requires=", None, "capabilities required to build this package", ), ("obsoletes=", None, "capabilities made obsolete by this package"), ("no-autoreq", None, "do not automatically calculate dependencies"), # Actions to take when building RPM ("keep-temp", "k", "don't clean up RPM build directory"), ("no-keep-temp", None, "clean up RPM build directory [default]"), # Add the hooks necessary for specifying custom scripts ( "prep-script=", None, "Specify a script for the PREP phase of RPM building", ), ( "build-script=", None, "Specify a script for the BUILD phase of RPM building", ), ( "pre-install=", None, "Specify a script for the pre-INSTALL phase of RPM building", ), ( "install-script=", None, "Specify a script for the INSTALL phase of RPM building", ), ( "post-install=", None, "Specify a script for the post-INSTALL phase of RPM building", ), ( "pre-uninstall=", None, "Specify a script for the pre-UNINSTALL phase of RPM building", ), ( "post-uninstall=", None, "Specify a script for the post-UNINSTALL phase of RPM building", ), ( "clean-script=", None, "Specify a script for the CLEAN phase of RPM building", ), ( "verify-script=", None, "Specify a script for the VERIFY phase of the RPM build", ), ("quiet", "q", "Run the INSTALL phase of RPM building in quiet mode"), ("debug", "g", "Run in debug mode"), ] boolean_options: ClassVar[list[str]] = [ "keep-temp", "no-autoreq", "quiet", "debug", ] negative_opt: ClassVar[dict[str, str]] = { "no-keep-temp": "keep-temp", } def initialize_options(self) -> None: self.bdist_base = None self.dist_dir = None self.rpm_base = None self.spec_only = None self.distribution_name = None self.group = None self.release = None self.serial = None self.vendor = None self.packager = None self.doc_files = None self.changelog = None self.icon = None self.prep_script = None self.build_script = None self.install_script = None self.clean_script = None self.verify_script = None self.pre_install = None self.post_install = None self.pre_uninstall = None self.post_uninstall = None self.prep = None self.provides = None self.requires = None self.conflicts = None self.build_requires = None self.obsoletes = None self.keep_temp = 0 self.no_autoreq = 0 self.quiet = 0 self.debug = 0 def finalize_options(self) -> None: if os.name != "posix": msg = ( "don't know how to create RPM " f"distributions on platform {os.name}" ) raise PlatformError(msg) self._rpm = shutil.which("rpm") self._rpmbuild = shutil.which("rpmbuild") if not self._rpmbuild: msg = "failed to find rpmbuild for this platform." raise PlatformError(msg) self.set_undefined_options( "bdist", ("bdist_base", "bdist_base"), ("dist_dir", "dist_dir"), ) if self.rpm_base is None: self.rpm_base = os.path.join(self.bdist_base, "rpm") self.finalize_package_data() def finalize_package_data(self) -> None: self.ensure_string("group", "Development/Libraries") contact = self.distribution.get_contact() or "UNKNOWN" contact_email = self.distribution.get_contact_email() or "UNKNOWN" self.ensure_string("vendor", f"{contact} <{contact_email}>") self.ensure_string("packager") self.ensure_string_list("doc_files") if isinstance(self.doc_files, list): doc_files = set(self.doc_files) for readme in ("README", "README.txt"): if os.path.exists(readme) and readme not in doc_files: self.doc_files.append(readme) self.ensure_string("release", "1") self.ensure_string("serial") # should it be an int? self.ensure_string("distribution_name") self.ensure_string("changelog") # Format changelog correctly self.changelog = self._format_changelog(self.changelog) self.ensure_filename("icon") self.ensure_filename("prep_script") self.ensure_filename("build_script") self.ensure_filename("install_script") self.ensure_filename("clean_script") self.ensure_filename("verify_script") self.ensure_filename("pre_install") self.ensure_filename("post_install") self.ensure_filename("pre_uninstall") self.ensure_filename("post_uninstall") # Now *this* is some meta-data that belongs in the setup script... self.ensure_string_list("provides") self.ensure_string_list("requires") self.ensure_string_list("conflicts") self.ensure_string_list("build_requires") self.ensure_string_list("obsoletes") def run(self) -> None: if self.debug: print("before _get_package_data():") print("vendor =", self.vendor) print("packager =", self.packager) print("doc_files =", self.doc_files) print("changelog =", self.changelog) # make directories if self.spec_only: spec_dir = self.dist_dir else: rpm_dir = {} for data in ("SOURCES", "SPECS", "BUILD", "RPMS", "SRPMS"): rpm_dir[data] = os.path.join(self.rpm_base, data) self.mkpath(rpm_dir[data]) spec_dir = rpm_dir["SPECS"] self.mkpath(self.dist_dir) # Spec file goes into 'dist_dir' if '--spec-only specified', # build/rpm. otherwise. distribution_name = self.distribution.get_name() spec_path = os.path.join(spec_dir, f"{distribution_name}.spec") self.execute( write_file, (spec_path, self._make_spec_file()), f"writing '{spec_path}'", ) if self.spec_only: # stop if requested return # Make a source distribution and copy to SOURCES directory with # optional icon. def exclude_filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None: if ( os.path.basename(info.name) in ("build", "dist") and info.isdir() ): return None return info name = self.distribution.get_name() version = self.distribution.get_version() source = f"{name}-{version}" source_dir = rpm_dir["SOURCES"] source_fullname = os.path.join(source_dir, source + ".tar.gz") with tarfile.open(source_fullname, "w:gz") as tar: tar.add(".", source, filter=exclude_filter) if self.icon: if os.path.exists(self.icon): self.copy_file(self.icon, source_dir) else: msg = f"icon file {self.icon!r} does not exist" raise FileError(msg) # build package, binary only (-bb) logging.info("building RPMs") rpm_cmd = [self._rpmbuild, "-bb"] if not self.keep_temp: rpm_cmd.append("--clean") if self.quiet: rpm_cmd.append("--quiet") rpm_cmd.append(spec_path) # Determine the binary rpm names that should be built out of this spec # file # Note that some of these may not be really built (if the file # list is empty) nvr_string = "%{name}-%{version}-%{release}" src_rpm = nvr_string + ".src.rpm" non_src_rpm = "%{arch}/" + nvr_string + ".%{arch}.rpm" q_cmd = [ self._rpm, "-q", "--qf", rf"{src_rpm} {non_src_rpm}\n", "--specfile", spec_path, ] try: out = check_output(q_cmd, text=True) except CalledProcessError as exc: msg = f"Failed to execute: {' '.join(q_cmd)!r}" raise ExecError(msg) from exc binary_rpms = [] for line in out.splitlines(): rows = line.split() assert len(rows) == 2 # noqa: S101 binary_rpms.append(rows[1]) self.spawn(rpm_cmd) if not self.dry_run: pyversion = get_python_version() for binary_rpm in binary_rpms: rpm = os.path.join(rpm_dir["RPMS"], binary_rpm) if os.path.exists(rpm): self.move_file(rpm, self.dist_dir) filename = os.path.join( self.dist_dir, os.path.basename(rpm) ) self.distribution.dist_files.append( ("bdist_rpm", pyversion, filename) ) def _make_spec_file(self) -> list[str]: """Generate the text of an RPM spec file and return it as a list of strings (one per line). """ # definitions and headers dist = self.distribution spec_file = [ f"%define _topdir {os.path.abspath(self.rpm_base)}", # cx_Freeze specific "%define __prelink_undo_cmd %{nil}", "%define __strip /bin/true", "", f"%define name {dist.get_name()}", f"%define version {dist.get_version().replace('-', '_')}", f"%define unmangled_version {dist.get_version()}", f"%define release {self.release.replace('-', '_')}", "", f"Summary: {dist.get_description() or 'UNKNOWN'}", "Name: %{name}", "Version: %{version}", "Release: %{release}", f"License: {dist.get_license() or 'UNKNOWN'}", f"Group: {self.group}", "BuildRoot: %{buildroot}", "Prefix: %{_prefix}", f"BuildArch: {platform.machine()}", ] # Fix for conda if IS_CONDA: spec_file.append("%define debug_package %{nil}") # Workaround for #14443 which affects some RPM based systems such as # RHEL6 (and probably derivatives) vendor_hook = check_output( [self._rpm, "--eval", "%{__os_install_post}"], text=True ) # Generate a potential replacement value for __os_install_post (whilst # normalizing the whitespace to simplify the test for whether the # invocation of brp-python-bytecompile passes in __python): vendor_hook = "\n".join( [f" {line.strip()} \\" for line in vendor_hook.splitlines()] ) problem = "brp-python-bytecompile \\\n" fixed = "brp-python-bytecompile %{__python} \\\n" fixed_hook = vendor_hook.replace(problem, fixed) if fixed_hook != vendor_hook: spec_file += [ "# Workaround for http://bugs.python.org/issue14443", f"%define __python {sys.executable}", f"%define __os_install_post {fixed_hook}", "", ] # we create the spec file before running 'tar' in case of --spec-only. spec_file.append("Source0: %{name}-%{unmangled_version}.tar.gz") for field in ( "Vendor", "Packager", "Provides", "Requires", "Conflicts", "Obsoletes", ): val = getattr(self, field.lower()) if isinstance(val, list): join_val = " ".join(val) spec_file.append(f"{field}: {join_val}") elif val is not None: spec_file.append(f"{field}: {val}") if dist.get_url() not in (None, "UNKNOWN"): spec_file.append(f"Url: {dist.get_url()}") if self.distribution_name: spec_file.append(f"Distribution: {self.distribution_name}") if self.build_requires: spec_file.append("BuildRequires: " + " ".join(self.build_requires)) if self.icon: spec_file.append("Icon: " + os.path.basename(self.icon)) if self.no_autoreq: spec_file.append("AutoReq: 0") spec_file += [ "", "%description", dist.get_long_description() or dist.get_description() or "UNKNOWN", ] # rpm scripts - figure out default build script if dist.script_name == "cxfreeze": def_setup_call = shutil.which(dist.script_name) else: def_setup_call = f"{sys.executable} {dist.script_name}" def_build = f"{def_setup_call} build_exe --optimize=1 --silent" def_build = 'env CFLAGS="$RPM_OPT_FLAGS" ' + def_build # insert contents of files # this is kind of misleading: user-supplied options are files # that we open and interpolate into the spec file, but the defaults # are just text that we drop in as-is. install_cmd = ( f"{def_setup_call} install --skip-build" " --prefix=%{_prefix} --root=%{buildroot}" ) script_options = [ ("prep", "prep_script", "%setup -n %{name}-%{unmangled_version}"), ("build", "build_script", def_build), ("install", "install_script", install_cmd), ("clean", "clean_script", "rm -rf %{buildroot}"), ("verifyscript", "verify_script", None), ("pre", "pre_install", None), ("post", "post_install", None), ("preun", "pre_uninstall", None), ("postun", "post_uninstall", None), ] for rpm_opt, attr, default in script_options: # Insert contents of file referred to, if no file is referred to # use 'default' as contents of script val = getattr(self, attr) if val or default: spec_file.extend(["", "%" + rpm_opt]) if val: with open(val, encoding="utf_8") as file: spec_file.extend(file.read().split("\n")) else: spec_file.append(default) # files section spec_file += [ "", "%files", "%dir %{_prefix}/lib/%{name}-%{unmangled_version}", "%{_prefix}/lib/%{name}-%{unmangled_version}/*", "%{_bindir}/%{name}", "%defattr(-,root,root)", ] if self.doc_files: spec_file.append("%doc " + " ".join(self.doc_files)) if self.changelog: spec_file.extend(["", "%changelog"]) spec_file.extend(self.changelog) return spec_file @staticmethod def _format_changelog(changelog) -> list[str]: """Format the changelog correctly and convert it to a string list.""" if not changelog: return changelog new_changelog = [] for raw_line in changelog.strip().split("\n"): line = raw_line.strip() if line[0] == "*": new_changelog.extend(["", line]) elif line[0] == "-": new_changelog.append(line) else: new_changelog.append(" " + line) # strip trailing newline inserted by first changelog entry if not new_changelog[0]: del new_changelog[0] return new_changelog def write_file(filename, contents) -> None: """Create a file with the specified name and write 'contents' (a sequence of strings without line terminators) to it. """ with open(filename, "w", encoding="utf_8") as file: for line in contents: file.write(line + "\n")