Skip to content

Package formats (package)

The package-format registry and its built-in formats. build writes one package per declared package from the file DB and the build root. A third-party format subclasses PackageFormat with its own NAME and advertises it in the pkgforge.package_formats entry-point group. The packages' user and group names, modes and times are read from the DB and the build root the same way for every format.

pkgforge.package.build(db, fmt, output, *, buildroot, config=None, config_path=None, arch=None, compression=None)

Build one package per declared package from the live entries of db and the files under buildroot, in the format fmt.

output is the directory the packages are written into (created with the first package; a failed build leaves nothing behind). config names the packages (default: an empty configuration, which no format accepts) and config_path is the file it was read from. arch names the architecture of an any package in the format's own spelling (default: this machine's); compression is one the format offers (default: its first). Build-time stamps and file times are SOURCE_DATE_EPOCH when it is set, so two builds of one build root are byte-identical.

Returns the paths written, the main package first, then each [[packages]] element in order.

Raises :class:~pkgforge.exceptions.UsageError for an unknown format, a compression or architecture the format cannot take, a [package] key the format needs, a SOURCE_DATE_EPOCH that is not a number, an entry of a package config does not declare, a recorded path missing from (or of another type in) buildroot or below a symlink that leads outside it, or a missing optional dependency; :class:~pkgforge.exceptions.InvalidEntryError for an entry no format can represent and :class:~pkgforge.exceptions.DumpError for one this format cannot.

Source code in src/pkgforge/package/__init__.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def build(
    db: typing.Mapping[str, typing.Optional[FileEntry]],
    fmt: str,
    output: StrPath,
    *,
    buildroot: StrPath,
    config: typing.Optional[ProjectConfig] = None,
    config_path: typing.Optional[StrPath] = None,
    arch: typing.Optional[str] = None,
    compression: typing.Optional[str] = None,
) -> typing.List[Path]:
    """Build one package per declared package from the live entries of ``db``
    and the files under ``buildroot``, in the format ``fmt``.

    ``output`` is the directory the packages are written into (created with the
    first package; a failed build leaves nothing behind). ``config`` names the
    packages (default: an empty configuration, which no format accepts) and
    ``config_path`` is the file it was read from.
    ``arch`` names the architecture of an ``any`` package in the format's own
    spelling (default: this machine's); ``compression`` is one the format
    offers (default: its first). Build-time stamps and file times are
    ``SOURCE_DATE_EPOCH`` when it is set, so two builds of one build root are
    byte-identical.

    Returns the paths written, the main package first, then each
    ``[[packages]]`` element in order.

    Raises :class:`~pkgforge.exceptions.UsageError` for an unknown format, a
    compression or architecture the format cannot take, a ``[package]`` key the
    format needs, a ``SOURCE_DATE_EPOCH`` that is not a number, an entry of a
    package ``config`` does not declare, a recorded path missing from (or of
    another type in) ``buildroot`` or below a symlink that leads outside it,
    or a missing optional dependency;
    :class:`~pkgforge.exceptions.InvalidEntryError` for an entry no format can
    represent and :class:`~pkgforge.exceptions.DumpError` for one this format
    cannot.
    """
    cls = PackageFormat.get(fmt)
    if compression is None:
        chosen = cls.COMPRESSIONS[0] if cls.COMPRESSIONS else None
    elif not cls.COMPRESSIONS:
        raise UsageError(f"{cls.NAME} takes no --compression")
    elif compression not in cls.COMPRESSIONS:
        raise UsageError(
            f"{cls.NAME} takes --compression {', '.join(cls.COMPRESSIONS)}"
        )
    else:
        chosen = compression
    if arch is not None and not _ARCH_RE.fullmatch(arch):
        raise UsageError(f"--arch {arch!r} is not an architecture name ([a-z0-9_]+)")
    config = config if config is not None else ProjectConfig()
    _require_keys(cls.NAME, config, cls.REQUIRED, cls.SUB_REQUIRED)
    epoch = _source_date_epoch()
    entries = _surviving_entries(db, ())
    _validate_entries(entries)
    payloads = _payloads(entries, config)
    writer = PackageWriter()
    context = BuildContext(
        config=config,
        buildroot=Path(os.fspath(buildroot)),
        output=Path(os.fspath(output)),
        compression=chosen,
        arch=arch,
        epoch=epoch,
        config_path=None if config_path is None else Path(os.fspath(config_path)),
        writer=writer,
    )
    try:
        written = cls().build(payloads, context)
        writer.commit()
    except BaseException:
        writer.abort()
        raise
    for path in written:
        _LOGGER.info("Wrote %s", path)
    _LOGGER.info("built %d package(s) with %s", len(written), cls.NAME)
    return written

pkgforge.package.BuildContext(config, buildroot, output, compression, arch, epoch, config_path=None, writer=PackageWriter()) dataclass

What a format builds against besides the payloads.

config is the project configuration, buildroot the directory that maps to / in the DB, output the directory the packages are written into, compression the chosen payload compression (None for a format that offers none), arch the --arch value, epoch the SOURCE_DATE_EPOCH seconds (None when unset) and config_path the file config was read from when known. A format creates every file it writes through writer, which keeps the build all or nothing and makes output only once there is a file to put in it.

arch instance-attribute

buildroot instance-attribute

compression instance-attribute

config instance-attribute

config_path = None class-attribute instance-attribute

epoch instance-attribute

output instance-attribute

writer = dataclasses.field(default_factory=PackageWriter, repr=False, compare=False) class-attribute instance-attribute

pkgforge.package.PackagePayload

Bases: NamedTuple

One package to build.

arch instance-attribute

entries instance-attribute

info instance-attribute

main instance-attribute

name instance-attribute

pkgforge.package.PackageFormat

Bases: Registered, ABC

A package format, selected by package -f NAME.

A subclass registers by declaring its own NAME (plus, optionally, ALIASES); see :class:pkgforge._registry.Registered.

ALIASES = () class-attribute

ALL_ARCH = 'all' class-attribute

COMPRESSIONS = () class-attribute

MACHINES = {} class-attribute

NAME = '' class-attribute

REQUIRED = () class-attribute

SUB_REQUIRED = () class-attribute

arch_name(payload, context)

The architecture name payload carries in this format: :attr:ALL_ARCH for an all package, else context.arch verbatim, else this machine's through :attr:MACHINES.

Raises :class:~pkgforge.exceptions.UsageError for a machine the table lacks.

Source code in src/pkgforge/package/__init__.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def arch_name(self, payload: PackagePayload, context: BuildContext) -> str:
    """The architecture name ``payload`` carries in this format:
    :attr:`ALL_ARCH` for an ``all`` package, else ``context.arch``
    verbatim, else this machine's through :attr:`MACHINES`.

    Raises :class:`~pkgforge.exceptions.UsageError` for a machine the
    table lacks.
    """
    if payload.arch == "all":
        return self.ALL_ARCH
    if context.arch:
        return context.arch
    machine = platform.machine().lower()
    try:
        return self.MACHINES[machine]
    except KeyError:
        raise UsageError(
            f"{self.NAME}: no architecture name for machine {machine!r}: "
            "pass --arch"
        ) from None

build(payloads, context) abstractmethod

Write one package per payload into context.output and return their paths in payload order.

Source code in src/pkgforge/package/__init__.py
108
109
110
111
112
113
@abc.abstractmethod
def build(
    self, payloads: typing.Sequence[PackagePayload], context: BuildContext
) -> typing.List[Path]:
    """Write one package per payload into ``context.output`` and return
    their paths in payload order."""

pkgforge.package.PackageWriter()

Creates the package files of one :func:~pkgforge.package.build.

Every file is written to a sibling .NAME.pkgforge-tmp and renamed over its final path by :meth:commit, so a build that fails leaves every target as it was. The output directory, and any parent it lacks, is created with the first file and removed again when the build fails.

Source code in src/pkgforge/package/_writer.py
23
24
25
26
def __init__(self) -> None:
    self._temps: typing.Dict[Path, Path] = {}
    self._open: typing.List[typing.BinaryIO] = []
    self._created: typing.List[Path] = []

abort()

Remove every file not yet renamed and every directory created.

Source code in src/pkgforge/package/_writer.py
70
71
72
73
74
75
76
77
78
79
80
81
82
def abort(self) -> None:
    """Remove every file not yet renamed and every directory created."""
    for handle in self._open:
        with contextlib.suppress(OSError):
            handle.close()
    for temp in self._temps.values():
        with contextlib.suppress(FileNotFoundError):
            temp.unlink()
    for directory in reversed(self._created):
        with contextlib.suppress(OSError):
            directory.rmdir()
    self._temps.clear()
    self._created.clear()

commit()

Rename every file to its path, in creation order.

Every target is checked before the first rename, so a directory in the way fails the build with nothing renamed.

Source code in src/pkgforge/package/_writer.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def commit(self) -> None:
    """Rename every file to its path, in creation order.

    Every target is checked before the first rename, so a directory in the
    way fails the build with nothing renamed.
    """
    for handle in self._open:
        handle.close()
    for path in self._temps:
        if path.is_dir():
            raise UsageError(f"cannot write {path}: a directory is in the way")
    for path, temp in self._temps.items():
        os.replace(temp, path)
    self._temps.clear()
    self._created.clear()

create(path)

Open the file that :meth:commit renames to path, for writing.

The caller closes it before :meth:commit.

Source code in src/pkgforge/package/_writer.py
41
42
43
44
45
46
47
48
def create(self, path: Path) -> typing.BinaryIO:
    """Open the file that :meth:`commit` renames to ``path``, for writing.

    The caller closes it before :meth:`commit`.
    """
    handle = open(self._temp(path), "wb")
    self._open.append(handle)
    return handle

move(source, path)

Take the finished file source as the content of path.

Source code in src/pkgforge/package/_writer.py
50
51
52
def move(self, source: Path, path: Path) -> None:
    """Take the finished file ``source`` as the content of ``path``."""
    shutil.move(os.fspath(source), os.fspath(self._temp(path)))

Built-in formats

pkgforge.package.ApkFormat

Bases: PackageFormat

NAME-VERSION-rREL.apk per package: an apk v2 file, which apk-tools 2 and 3 install with --allow-untrusted.

It is two gzip members back to back: the control tar (.PKGINFO and the scripts, with no end-of-archive blocks) and the data tar (pax, owner names from the DB, and a APK-TOOLS.checksum.SHA1 record on every file and symlink). .PKGINFO carries the datahash of the data member, which apk-tools 3 requires. apk v3 packages are not written.

ALL_ARCH = 'noarch' class-attribute instance-attribute

COMPRESSIONS = ('gz',) class-attribute instance-attribute

MACHINES = {'x86_64': 'x86_64', 'amd64': 'x86_64', 'aarch64': 'aarch64', 'arm64': 'aarch64', 'armv7l': 'armv7', 'i686': 'x86', 'i386': 'x86', 'riscv64': 'riscv64', 'ppc64le': 'ppc64le', 's390x': 's390x'} class-attribute instance-attribute

NAME = 'apk' class-attribute instance-attribute

REQUIRED = ('version', 'summary') class-attribute instance-attribute

SUB_REQUIRED = ('summary',) class-attribute instance-attribute

build(payloads, context)

Source code in src/pkgforge/package/_apk.py
213
214
215
216
217
218
219
220
221
222
223
224
def build(
    self, payloads: typing.Sequence[PackagePayload], context: BuildContext
) -> typing.List[Path]:
    when = stamp(context.epoch)
    files = []
    for payload in payloads:
        arch = self.arch_name(payload, context)
        version = _version(payload)
        path = context.output / f"{payload.name}-{version}.apk"
        self._package(payload, context, version, arch, when, path)
        files.append(path)
    return files

pkgforge.package.ArchFormat

Bases: PackageFormat

NAME-VERSION-PKGREL-ARCH.pkg.tar.{zst,xz,gz} per package.

The tar is GNU, with .PKGINFO, .MTREE and, when it has a command, .INSTALL ahead of the payload. pacman ignores the owner names of a tar, so every header is root:root and .INSTALL applies each other owner or group (creating the accounts first); pacman -Qkk reports an owner mismatch for those paths.

ALL_ARCH = 'any' class-attribute instance-attribute

COMPRESSIONS = ('zst', 'xz', 'gz') class-attribute instance-attribute

MACHINES = {'x86_64': 'x86_64', 'amd64': 'x86_64', 'aarch64': 'aarch64', 'arm64': 'aarch64', 'armv7l': 'armv7h', 'i686': 'i686', 'i386': 'i686', 'riscv64': 'riscv64'} class-attribute instance-attribute

NAME = 'arch' class-attribute instance-attribute

REQUIRED = ('version', 'summary') class-attribute instance-attribute

SUB_REQUIRED = ('summary',) class-attribute instance-attribute

build(payloads, context)

Source code in src/pkgforge/package/_arch.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def build(
    self, payloads: typing.Sequence[PackagePayload], context: BuildContext
) -> typing.List[Path]:
    assert context.compression is not None
    when = stamp(context.epoch)
    files = []
    for payload in payloads:
        arch = self.arch_name(payload, context)
        version = f"{payload.main.version}-{_pkgrel(payload.main.release)}"
        name = f"{payload.name}-{version}-{arch}.pkg.tar"
        path = context.output / (name + SUFFIXES[context.compression])
        self._package(payload, context, version, arch, when, path)
        files.append(path)
    return files

pkgforge.package.DebFormat

Bases: PackageFormat

NAME_VERSION[-RELEASE]_ARCH.deb per package.

The ar archive holds debian-binary, control.tar and data.tar, both GNU tar with a ./ root, as debhelper builds them. The control file, conffiles (regular files below /etc and config kinds elsewhere), md5sums and the maintainer scripts match what debhelper builds from the same configuration; postinst, prerm and postrm are written only with a command in them (sysusers, tmpfiles, units, dpkg-statoverride for an account created at install time, setcap).

ALL_ARCH = 'all' class-attribute instance-attribute

COMPRESSIONS = ('xz', 'gz', 'zst', 'none') class-attribute instance-attribute

MACHINES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64', 'armv7l': 'armhf', 'i686': 'i386', 'i386': 'i386', 'riscv64': 'riscv64', 'ppc64le': 'ppc64el', 's390x': 's390x'} class-attribute instance-attribute

NAME = 'deb' class-attribute instance-attribute

REQUIRED = ('version', 'summary', 'maintainer') class-attribute instance-attribute

SUB_REQUIRED = ('summary',) class-attribute instance-attribute

build(payloads, context)

Source code in src/pkgforge/package/_deb.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def build(
    self, payloads: typing.Sequence[PackagePayload], context: BuildContext
) -> typing.List[Path]:
    assert context.compression is not None
    when = stamp(context.epoch)
    files = []
    for payload in payloads:
        arch = self.arch_name(payload, context)
        main = payload.main
        version = f"{main.version}-{main.release}" if main.release else main.version
        assert version is not None  # REQUIRED
        path = context.output / f"{payload.name}_{version}_{arch}.deb"
        self._deb(payload, context, version, arch, when, path)
        files.append(path)
    return files

pkgforge.package.RpmFormat

Bases: PackageFormat

NAME-VERSION-RELEASE.ARCH.rpm per package, built by rpmbuild.

The files are the recorded, non-ghost entries of the build root, packed into a tar that the spec's %install unpacks, so rpm applies the DB's modes, owners and kinds through the %files lists dbdump -f rpmspecfiles writes (the -pre419 variants below rpm 4.19). The spec is the one dbdump -f rpmspec writes, with the unit scriptlets (they expand to nothing, with a warning, where rpm has no systemd macros). Needs rpmbuild on PATH and a summary per [[packages]] element; --compression is refused, since rpm owns its payload, and --arch is passed as rpmbuild --target. The build happens in a temporary directory that is removed afterwards; the packages are moved into the output directory.

NAME = 'rpm' class-attribute instance-attribute

REQUIRED = ('version', 'summary', 'license') class-attribute instance-attribute

SUB_REQUIRED = ('summary',) class-attribute instance-attribute

build(payloads, context)

Source code in src/pkgforge/package/_rpm.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def build(
    self, payloads: typing.Sequence[PackagePayload], context: BuildContext
) -> typing.List[Path]:
    rpmbuild = shutil.which("rpmbuild")
    if rpmbuild is None:
        raise PkgForgeError("package -f rpm needs rpmbuild on PATH")
    legacy = _rpm_version(rpmbuild) < _SYSUSERS_RPM
    entries = [pair for payload in payloads for pair in payload.entries]
    if unit_names(entries) and _eval(rpmbuild, _SYSTEMD_MACROS) != "1":
        _LOGGER.warning(
            "rpm has no systemd macros: the unit scriptlets are left out "
            "(install systemd-rpm-macros)"
        )
    main = payloads[0].main
    top = Path(tempfile.mkdtemp(prefix="pkgforge-rpm-"))
    try:
        for name in ("SPECS", "RPMS", "BUILD", "BUILDROOT", "SOURCES", "SRPMS"):
            (top / name).mkdir()
        items = sorted(
            (
                item
                for payload in payloads
                for item in members(
                    payload.entries,
                    context.buildroot,
                    epoch=context.epoch,
                    parents=False,
                )
            ),
            key=lambda item: item.path,
        )
        with open(top / "payload.tar", "wb") as handle:
            write_tar(handle, items, format=tarfile.GNU_FORMAT)
        lists = top / "lists"
        lists.mkdir()
        dbdump.dump(
            dict(entries),
            "rpmspecfiles-pre419" if legacy else "rpmspecfiles",
            lists,
            config=context.config,
        )
        spec = _spec(
            context.config,
            entries,
            fmt=self.NAME,
            sysusers_pre=legacy,
            install=_INSTALL,
            guard=False,
        )
        spec_path = top / "SPECS" / f"{main.name}.spec"
        spec_path.write_bytes(spec.encode("utf-8"))
        argv = [
            rpmbuild,
            "-bb",
            "--define",
            f"_topdir {top}",
            "--define",
            f"pkgforge_payload {top / 'payload.tar'}",
            "--define",
            f"pkgforge_lists {lists}",
        ]
        if context.arch:
            argv += ["--target", context.arch]
        _run_tool([*argv, str(spec_path)], what="package -f rpm")
        return self._collect(payloads, top / "RPMS", context)
    finally:
        shutil.rmtree(top, ignore_errors=True)

pkgforge.package.TarFormat

Bases: PackageFormat

NAME-VERSION[-RELEASE].tar[.gz|.xz|.zst] per package.

GNU tar, names without ./ and no entry for the root, so extracting over / touches nothing it does not ship. Ghost entries are left out. A file capability cannot be stored in the archive; it is logged as a warning.

COMPRESSIONS = ('gz', 'xz', 'zst', 'none') class-attribute instance-attribute

NAME = 'tar' class-attribute instance-attribute

REQUIRED = ('version',) class-attribute instance-attribute

build(payloads, context)

Source code in src/pkgforge/package/_tar.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def build(
    self, payloads: typing.Sequence[PackagePayload], context: BuildContext
) -> typing.List[Path]:
    assert context.compression is not None
    files = []
    for payload in payloads:
        items = members(payload.entries, context.buildroot, epoch=context.epoch)
        for item in items:
            if item.entry is not None and item.entry["caps"] != DEFAULT:
                _LOGGER.warning(
                    "%s: a tar carries no file capability; run setcap %s %s "
                    "after extracting",
                    item.path,
                    item.entry["caps"],
                    item.path,
                )
        version = payload.main.version
        release = f"-{payload.main.release}" if payload.main.release else ""
        name = f"{payload.name}-{version}{release}.tar"
        path = context.output / (name + SUFFIXES[context.compression])
        with context.writer.create(path) as handle:
            write_compressed_tar(
                handle, context.compression, items, format=tarfile.GNU_FORMAT
            )
        files.append(path)
    return files