From 1ccf5165495d2200159342b0c8b85ab2acda6bde Mon Sep 17 00:00:00 2001 From: TheAssassin Date: Thu, 31 Aug 2023 16:18:15 +0200 Subject: [PATCH] Introduce new meta info system This system allows us to dynamically pass new metadata without introducing new CLI flags and without touching anything but the required templates. --- README.md | 9 ++ ldnp/__main__.py | 91 +++++++++++-------- ldnp/{packager.py => abstractpackager.py} | 103 ++++++++++++++++++---- ldnp/deb.py | 37 ++++---- ldnp/rpm.py | 43 ++++----- ldnp/templates/deb/control | 66 +++++++------- ldnp/templates/rpm/spec | 16 +++- 7 files changed, 233 insertions(+), 132 deletions(-) rename ldnp/{packager.py => abstractpackager.py} (65%) diff --git a/README.md b/README.md index cd3f11e..1630725 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,12 @@ Additionally, the following environment variables are supported: - `LDNP_SHORT_DESCRIPTION`: Optional short package description for the package's metadata. - `LDNP_PACKAGE_NAME`: The package name to be configured in the metadata. If this is not set, the app name is used. - `LDNP_FILENAME_PREFIX`: By default, the package name is used. If this is insufficient, a custom value can be specified. + +Additional package meta information may be passed in using `LDNP_META_*` variables. Some examples: + +- `LDNP_META_DESCRIPTION`: +- `LDNP_META_SHORT_DESCRIPTION` +- `LDNP_META_RPM_DESCRIPTION` +- `LDNP_META_DEB_DESCRIPTION` +- `LDNP_META_DEB_DEPENDS` +- `LDNP_META_RPM_REQUIRES` diff --git a/ldnp/__main__.py b/ldnp/__main__.py index 60f8209..3f8c573 100644 --- a/ldnp/__main__.py +++ b/ldnp/__main__.py @@ -7,23 +7,35 @@ import click +from .abstractpackager import AbstractMetaInfo, AbstractPackager from .logging import set_up_logging, get_logger from .context import Context from .appdir import AppDir -from .deb import DebPackager -from .rpm import RpmPackager +from .deb import DebPackager, DebMetaInfo +from .rpm import RpmPackager, RpmMetaInfo -def make_packager( - build_type: str, appdir: AppDir, package_name: str, version: str, filename_prefix: str, context_path: Path -): +def make_meta_info(build_type: str) -> AbstractMetaInfo: + if build_type == "rpm": + meta_info = RpmMetaInfo() + + elif build_type == "deb": + meta_info = DebMetaInfo() + + else: + raise KeyError(f"cannot create packager for unknown build type {build_type}") + + return meta_info + + +def make_packager(build_type: str, appdir: AppDir, meta_info: AbstractMetaInfo, context_path: Path) -> AbstractPackager: context = Context(context_path) if build_type == "rpm": - packager = RpmPackager(appdir, package_name, version, filename_prefix, context) + packager = RpmPackager(appdir, meta_info, context) elif build_type == "deb": - packager = DebPackager(appdir, package_name, version, filename_prefix, context) + packager = DebPackager(appdir, meta_info, context) else: raise KeyError(f"cannot create packager for unknown build type {build_type}") @@ -50,12 +62,10 @@ def make_packager( @click.option("--sign", is_flag=True, default=False, show_envvar=True) @click.option("--gpg-key", default=None, show_envvar=True) @click.option("--debug", is_flag=True, default=False, envvar="DEBUG", show_envvar=True) +# compatibility with linuxdeploy output plugin spec flags, plugin-specific environment variables will always take +# precedence @click.option("--app-name", default=None, envvar="LINUXDEPLOY_OUTPUT_APP_NAME", show_envvar=True) @click.option("--package-version", default=None, envvar="LINUXDEPLOY_OUTPUT_VERSION", show_envvar=True) -@click.option("--package-name", default=None, envvar="LDNP_PACKAGE_NAME", show_envvar=True) -@click.option("--description", default=None, envvar="LDNP_DESCRIPTION", show_envvar=True) -@click.option("--short-description", default=None, envvar="LDNP_SHORT_DESCRIPTION", show_envvar=True) -@click.option("--filename-prefix", default=None, envvar="LDNP_FILENAME_PREFIX", show_envvar=True) def main( build: Iterable[str], appdir: str | os.PathLike, @@ -64,10 +74,6 @@ def main( debug: bool, app_name: str, package_version: str, - package_name: str, - description: str, - short_description: str, - filename_prefix: str, ): set_up_logging(debug) @@ -75,24 +81,6 @@ def main( appdir_instance = AppDir(appdir) - if app_name and not package_name: - logger.info(f"Using user-provided linuxdeploy output app name as package name: {app_name}") - package_name = app_name - elif package_name: - logger.info(f"Using user-provided package name: {package_name}") - else: - package_name = appdir_instance.guess_package_name() - - if not package_name: - logger.critical("No package name provided and guessing failed") - sys.exit(2) - - logger.info(f"Guessed package name {package_name}") - - if not filename_prefix: - logger.info("Using package name as filename prefix") - filename_prefix = package_name - if not package_version: try: package_version = appdir_instance.guess_package_version() @@ -103,10 +91,35 @@ def main( logger.info(f"Package version: {package_version}") for build_type in build: + meta_info = make_meta_info(build_type) + meta_info["version"] = package_version + + if app_name and not meta_info.get("package_name"): + logger.info(f"Using user-provided linuxdeploy output app name as package name: {app_name}") + meta_info["package_name"] = app_name + elif meta_info.get("package_name"): + logger.info(f"Using user-provided package name: {meta_info['package_name']}") + else: + guessed_package_name = appdir_instance.guess_package_name() + + if not guessed_package_name: + logger.critical("No package name provided and guessing failed") + sys.exit(2) + + meta_info["package_name"] = guessed_package_name + logger.info(f"Guessed package name {meta_info['package_name']}") + + if not meta_info.get("filename_prefix"): + logger.info("Using package name as filename prefix") + meta_info["filename_prefix"] = meta_info["package_name"] + with TemporaryDirectory(prefix="ldnp-") as td: - packager = make_packager( - build_type, appdir_instance, package_name, package_version, filename_prefix, Path(td) - ) + packager = make_packager(build_type, appdir_instance, meta_info, Path(td)) + + description = meta_info.get("description") + short_description = meta_info.get("short_description") + + print(meta_info["depends"]) if short_description and not description: logger.warning("No description provided, falling back to short description") @@ -118,14 +131,14 @@ def main( logger.warning("Neither description nor short description provided") if description: - packager.set_description(description) + meta_info["description"] = description if short_description: - packager.set_short_description(short_description) + meta_info["short_description"] = short_description # the correct filename suffix will be appended automatically if not specified # for now, we just build the package in the current working directory - out_path = Path(os.getcwd()) / package_name + out_path = Path(os.getcwd()) / meta_info["package_name"] if package_version: out_path = Path(f"{out_path}_{package_version}") diff --git a/ldnp/packager.py b/ldnp/abstractpackager.py similarity index 65% rename from ldnp/packager.py rename to ldnp/abstractpackager.py index 481671e..c581915 100644 --- a/ldnp/packager.py +++ b/ldnp/abstractpackager.py @@ -3,6 +3,7 @@ import shlex import shutil import stat +from collections import UserDict from pathlib import Path from typing import Iterable @@ -15,31 +16,97 @@ logger = get_logger("packager") -class Packager: - def __init__(self, appdir: AppDir, package_name: str, version: str, filename_prefix: str, context: Context): +class AbstractMetaInfo(UserDict): + """ + Base class for meta information from which the packaging files are generated. + + The metadata needed for either supported packager can differ to some extent, but there are also shared values + which are implemented here. + + The meta information is typically passed to the application via environment variables which follow a specific + pattern: + + "LDNP_" as a prefix, "META_" to identify it as meta information and an arbitrary identifier suffix. + + Optionally, before the identifier suffix, a packager designation may be passed to use the information only for a + specific packager. Currently, DEB_ and RPM_ are supported. + + The identifier may contain any character that is valid for an environment variable. It is recommended to only use + uppercase alphanumeric characters [A-Z0-9] and underscores _. + + For instance, a package description could be passed to the meta information system as "LDNP_META_DESCRIPTION". + An RPM-only description would be passed as "LDNP_META_RPM_DESCRIPTION". + + If the packager cannot make any use of the information provided in the environment, the value will be ignored. + + Packager-specific implementations need to implement the packager_prefix method, returning a unique designator. + """ + + # as some meta information may be set programmatically, too, we need a "cache" for those values + # information provided via environment variables currently always take precedence over those programmatically + # provided values + # all identifiers are case supposed to be case-insensitive and will be normalized to upper case (which matches + # the environment variables) + + @staticmethod + def packager_prefix(): + raise NotImplementedError + + def __setitem__(self, key, value): + # we treat all keys as case-insensitive and normalize them to uppercase + self.data[key.upper()] = value + + def __getitem__(self, identifier: str): + """ + Implements the "subscript" operator. Checks the environment for packager-specific and globally set meta info. + + :param identifier: identifier suffix (see class description) + :return: value for provided identifier (if available) + :raises KeyError: if the requested value is unavailable + """ + + # identifiers are supposed to be case insensitive within our code (we accept only upper-case env vars) + identifier = identifier.upper() + + prefix = "LDNP_META" + + global_env_var = f"{prefix}_{identifier}" + specific_env_var = f"{prefix}_{self.packager_prefix()}_{identifier.upper()}" + + # just needed to be able to rewrite the error message + try: + try: + return os.environ[specific_env_var] + + except KeyError: + try: + return os.environ[global_env_var] + + except KeyError: + # the KeyError here should propagate to the caller if raised + return self.data[identifier] + + except KeyError: + raise KeyError(f"Could not find {identifier.upper()}") + + +class AbstractPackager: + def __init__(self, appdir: AppDir, meta_info: AbstractMetaInfo, context: Context): self.appdir = appdir + self.meta_info = meta_info self.context: Context = context - # we require these values, so the CLI needs to either demand them from the user or set sane default values - # TODO: validate these input values - self.package_name = package_name - self.version = version - self.filename_prefix = filename_prefix + assert self.meta_info["package_name"] + assert self.meta_info["version"] + assert self.meta_info["filename_prefix"] - self.appdir_installed_path = Path(f"/opt/{self.package_name}.AppDir") + self.appdir_installed_path = Path(f"/opt/{self.meta_info['package_name']}.AppDir") self.appdir_install_path = self.context.install_root_dir / str(self.appdir_installed_path).lstrip("/") logger.debug(f"AppDir install path: {self.appdir_install_path}") - # optional values that _can_ but do not have to be set - # for these values, we internally provide default values in the templates - self.description = None - self.short_description = None - - def set_description(self, description: str): - self.description = description - - def set_short_description(self, short_description: str): - self.description = short_description + @staticmethod + def make_meta_info(): + raise NotImplementedError def find_desktop_files(self) -> Iterable[Path]: rv = glob.glob(str(self.appdir_install_path / AppDir.DESKTOP_FILES_RELATIVE_LOCATION / "*.desktop")) diff --git a/ldnp/deb.py b/ldnp/deb.py index ff12a1d..fcd7328 100644 --- a/ldnp/deb.py +++ b/ldnp/deb.py @@ -3,8 +3,7 @@ from pathlib import Path -from .context import Context -from .packager import Packager, AppDir +from .abstractpackager import AbstractPackager, AbstractMetaInfo from .templating import jinja_env from .util import run_command from .logging import get_logger @@ -13,11 +12,21 @@ logger = get_logger("deb") -class DebPackager(Packager): +class DebMetaInfo(AbstractMetaInfo): + @staticmethod + def packager_prefix(): + return "DEB" + + +class DebPackager(AbstractPackager): """ This class is inspired by CPack's DEB generator code. """ + @staticmethod + def make_meta_info(): + return DebMetaInfo() + def generate_control_file(self): # this key is optional, however it shouldn't be a big deal to calculate the value installed_size = sum( @@ -27,23 +36,15 @@ def generate_control_file(self): ) ) - assert self.version - assert self.package_name - - metadata = { - "installed_size": installed_size, - "version": self.version, - "package_name": self.package_name, - } - - if self.description: - metadata["description"] = self.description - - if self.short_description: - metadata["short_description"] = self.short_description + try: + self.meta_info.get("version") + self.meta_info.get("package_name") + except KeyError: + assert False # sorting is technically not needed but makes reading and debugging easier - rendered = jinja_env.get_template("deb/control").render(**metadata) + # note: installed_size is packager specific and must not be overwritten by the user, so we pass it separately + rendered = jinja_env.get_template("deb/control").render(meta_info=self.meta_info, installed_size=installed_size) # a binary control file may not contain any empty lines in the main body, but must have a trailing one dual_newline = "\n" * 2 diff --git a/ldnp/rpm.py b/ldnp/rpm.py index 55f9035..dc552e2 100644 --- a/ldnp/rpm.py +++ b/ldnp/rpm.py @@ -5,8 +5,7 @@ import gnupg -from .context import Context -from .packager import Packager +from .abstractpackager import AbstractPackager, AbstractMetaInfo from .templating import jinja_env from .logging import get_logger from .util import run_command @@ -37,11 +36,21 @@ def is_any_parent_dir_a_symlink(root_dir: Path, relative_file_path: Path): return False -class RpmPackager(Packager): +class RpmMetaInfo(AbstractMetaInfo): + @staticmethod + def packager_prefix(): + return "RPM" + + +class RpmPackager(AbstractPackager): """ This class is inspired by CPack's DEB generator code. """ + @staticmethod + def make_meta_info(): + return RpmMetaInfo() + def generate_spec_file(self): files_and_directories = list( map(Path, glob.glob(str(self.context.install_root_dir / "**"), recursive=True, include_hidden=True)) @@ -76,28 +85,22 @@ def generate_spec_file(self): files.append(path_to_include) - assert self.version - assert self.package_name - - # try to automagically fix the version number if needed to make it work with rpm - fixed_version = self.version.replace("-", "_") - - if fixed_version != self.version: - logger.warning(f"version number {self.version} incompatible, changed to: {fixed_version}") + assert self.meta_info["package_name"] - metadata = { - "version": fixed_version, - "package_name": self.package_name, - } + version = self.meta_info["version"] + assert version - if self.description: - metadata["description"] = self.description + # try to automagically fix the version number if needed to make it work with rpm + fixed_version = version.replace("-", "_") - if self.short_description: - metadata["short_description"] = self.short_description + if fixed_version != version: + logger.warning(f"version number {version} incompatible, changed to: {fixed_version}") # sorting is technically not needed but makes reading and debugging easier - rendered = jinja_env.get_template("rpm/spec").render(files=list(sorted(files)), **metadata) + # note: fixed_version is packager-specific, so we pass it separately + rendered = jinja_env.get_template("rpm/spec").render( + files=list(sorted(files)), meta_info=self.meta_info, fixed_version=fixed_version + ) with open(self.context.work_dir / "package.spec", "w") as f: f.write(rendered) diff --git a/ldnp/templates/deb/control b/ldnp/templates/deb/control index 08454cd..e586932 100644 --- a/ldnp/templates/deb/control +++ b/ldnp/templates/deb/control @@ -1,59 +1,59 @@ {# see https://www.debian.org/doc/debian-policy/ch-controlfields.html for more information #} {# mandatory values #} -Package: {{ package_name | default("ldnp-unknown") }} -Version: {{ version | default("0.0.1-1unknown1") }} -Architecture: {{ architecture | default("amd64") }} -Maintainer: {{ maintainer_name | default("ldnp user") }} <{{ maintainer_email | default("user@ldnp") }}> -Description: {{ description | default(short_description) | default("ldnp-built package") }} +Package: {{ meta_info.get("package_name") | default("ldnp-unknown", true) }} +Version: {{ meta_info.get("version") | default("0.0.1-1unknown1", true) }} +Architecture: {{ meta_info.get("architecture") | default("amd64", true) }} +Maintainer: {{ meta_info.get("maintainer_name") | default("ldnp user") }} <{{ maintainer_email | default("user@ldnp", true) }}> +Description: {{ meta_info.get("description") | default(meta_info.get("short_description")) | default("ldnp-built package", true) }} {# recommended values #} -Section: {{ section | default("misc") }} -Priority: {{ priority | default("standard") }} +Section: {{ meta_info.get("section") | default("misc", true) }} +Priority: {{ meta_info.get("priority") | default("standard", true) }} {# optional values #} {# we can always calculate the installed size estimation #} Installed-Size: {{ installed_size }} -{%- if source %} -Source: {{ source }} +{%- if meta_info.get("source") %} +Source: {{ meta_info["source"] }} {%- endif %} -{%- if essential %} -Essential: {{ essential }} +{%- if meta_info.get("essential") %} +Essential: {{ meta_info["essential"] }} {%- endif %} -{%- if depends %} -Depends: {{ depends }} +{%- if meta_info.get("depends") %} +Depends: {{ meta_info["depends"] }} {%- endif %} -{%- if pre_depends %} -Pre-Depends: {{ pre_depends }} +{%- if meta_info.get("pre_depends") %} +Pre-Depends: {{ meta_info["depends"] }} {%- endif %} -{%- if recommends %} -Recommends: {{ recommends }} +{%- if meta_info.get("recommends") %} +Recommends: {{ meta_info["recommends"] }} {%- endif %} -{%- if suggests %} -Suggests: {{ suggests }} +{%- if meta_info.get("suggests") %} +Suggests: {{ meta_info["suggests"] }} {%- endif %} -{%- if breaks %} -Breaks: {{ breaks }} +{%- if meta_info.get("breaks") %} +Breaks: {{ meta_info["breaks"] }} {%- endif %} -{%- if conflicts %} -Conflicts: {{ conflicts }} +{%- if meta_info.get("conflicts") %} +Conflicts: {{ meta_info["conflicts"] }} {%- endif %} -{%- if provides %} -Provides: {{ provides }} +{%- if meta_info.get("provides") %} +Provides: {{ meta_info["provides"] }} {%- endif %} -{%- if source %} -Source: {{ source }} +{%- if meta_info.get("source") %} +Source: {{ meta_info["source"] }} {%- endif %} -{%- if replaces %} -Replaces: {{ replaces }} +{%- if meta_info.get("replaces") %} +Replaces: {{ meta_info["replaces"] }} {%- endif %} -{%- if enhances %} -Enhances: {{ enhances }} +{%- if meta_info.get("enhances") %} +Enhances: {{ meta_info["enhances"] }} {%- endif %} -{%- if homepage %} -Homepage: {{ homepage }} +{%- if meta_info.get("homepage") %} +Homepage: {{ meta_info["homepage"] }} {%- endif %} {# just some advertising... #} diff --git a/ldnp/templates/rpm/spec b/ldnp/templates/rpm/spec index 5ff52e1..784e0d1 100644 --- a/ldnp/templates/rpm/spec +++ b/ldnp/templates/rpm/spec @@ -1,7 +1,7 @@ -Name: {{ package_name | default("ldnp-unknown") }} -Version: {{ version | default("0.0.1") }} +Name: {{ meta_info.get("package_name") | default("ldnp-unknown", true) }} +Version: {{ fixed_version | default("0.0.1", true) }} Release: 1%{?dist} -Summary: {{ short_description | default("ldnp-built package") }} +Summary: {{ meta_info.get("short_description") | default("ldnp-built package", true) }} Group: System Environment/Base License: GPLv3+ @@ -17,8 +17,16 @@ License: GPLv3+ # this guessing is broken due to the fact that the AppDir contains almost all of the dependencies itself AutoReqProv: no +{% for req in meta_info.get("requires", "").split() %} +Requires: {{ req }} +{% endfor %} + +{% for prov in meta_info.get("provides", "").split() %} +Provides: {{ prov }} +{% endfor %} + %description -{{ description | default(short_description) | default("ldnp-built package") }} +{{ meta_info.get("description") | default(meta_info.get("short_description"), true) | default("ldnp-built package", true) }} %prep # nothing to do