Skip to content

Commit

Permalink
Introduce new meta info system
Browse files Browse the repository at this point in the history
This system allows us to dynamically pass new metadata without introducing new CLI flags and without touching anything but the required templates.
  • Loading branch information
TheAssassin committed Aug 31, 2023
1 parent 33f41ac commit 1ccf516
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 132 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
91 changes: 52 additions & 39 deletions ldnp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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,
Expand All @@ -64,35 +74,13 @@ 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)

logger = get_logger("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()
Expand All @@ -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")
Expand All @@ -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}")
Expand Down
103 changes: 85 additions & 18 deletions ldnp/packager.py → ldnp/abstractpackager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import shlex
import shutil
import stat
from collections import UserDict
from pathlib import Path
from typing import Iterable

Expand All @@ -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"))
Expand Down
37 changes: 19 additions & 18 deletions ldnp/deb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down
Loading

0 comments on commit 1ccf516

Please sign in to comment.