Changelog
Changelog
All notable changes to this project are documented here. The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
Unreleased
0.3.0 - 2026-10-22
Upgrading from 0.2.0
Every entry under Removed is also a break; its replacement is named there.
- Install PyYAML for a yaml DB: pip install 'pkgforge[yaml]'.
- Re-create every 0.2.0 file DB: a record lacks kind, caps and package, so
loading it fails. Run pkgforge initdb and stage again.
- Call pkgforge.cli.main(argv) where you called pkgforge.main(argv), or use
pkgforge.Project from a build script; import the entry functions from
pkgforge.entry and the exceptions from pkgforge.exceptions.
- Rename JsonlDb, YamlDb, SqliteDb, RpmSpecFiles, RpmSpecFilesPre419,
Debian and pkgforge.dbdump.Entries to JSONLinesDb, YAMLDb,
SQLiteDb, RPMSpecFiles, RPMSpecFilesPre419, DebianFiles and
pkgforge.FileEntries, and lookup(name) to get(name).
- Port a dump format of your own from dump(entries, output, logger) to
render(entries, context), check_output(output) and write(rendered,
output, *, force=False).
- Rename a third-party backend or format whose NAME, alias or suffix clashes
with another class's: the second one raises ValueError.
- Rewrite an absolute install/scan -X pattern that names the install path
(-X /opt/app/build/tmp) relative to the source (-X /tmp), and
PathMatch(stmts, installroot=...) as PathMatch(stmts, root=...).
- Replace dbdump --stdin with dbdump --db -, DUHO_TRACEBACK with
PKGFORGE_TRACEBACK, and -f rpmspec (the %files lines) with -f
rpmspecfiles or -f rpm: rpmspec is the spec format.
- Pass --db-format for a file DB whose suffix does not name its format: its
content is not sniffed.
- Run pkgforge initdb before dbdump or compact on a DB that does not exist
yet: both exit 2 for it.
- Record owners and groups by account name, not number, and declare every
account outside Debian's base-passwd set in [[users]] or [[groups]] of a
pkgforge.cfg (create = false for one that exists on the target).
- Replace -O rpmprefix=... with -k/--kind and --caps.
- For dbdump -f debian, add a pkgforge.cfg with [package] name, delete
the nameless install, dirs, permissions and fixperms files the dump
wrote into debian/, and run sh debian/fixperms debian (the debian
directory, not the package's) from override_dh_fixperms.
- Remove any tail after -- from a command line (pkgforge initdb -- junk):
it is now a usage error, exit 2, where it was ignored.
Added
pkgforge.cfg, a TOML project file read from--config/-c FILE(before the subcommand), elsePKGFORGE_CONFIG(an empty value means no file), else./pkgforge.cfg.db,db_formatandbuildrootin it are defaults below the command line and the environment, and relative paths are relative to the file.${package.name}and any other key of the file can be reused in its text values ($${is a literal${). A YAML file's!includeand!loadare refused. The whole file is validated before any command runs: a problem is onepkgforge: error:line, status 2.pkgforge.configholds the schema (ProjectConfigand one dataclass per table) andload(path);Project(..., config=, config_path=),Project.config,Project.config_pathandProject.from_config(path)use it.[package](name,version,release,arch,summary,license,homepage,maintainer,description, each checked when the file is read;nameis a lowercase package name[a-z0-9][a-z0-9+.-]+) with[package.rpm] requires,[package.debian] depends,sectionandpriority,[package.pacman] dependsand[package.apk] depends, and[[packages]], the subpackages built from the same source.[defaults]and[[rules]]fill themode,owner,group,kind,capsandpackagean entry leaves at-when it is recorded: the first rule whosematch(-Xstatements) fits the DB path, then[defaults](dir_mode,exec_modeorfile_modeby type and execute bit,owner,group). A flag wins.installwithout-mstages the file with the resolved mode, andinstall --record-tree(andrecord_tree = truein a[[stage]]step) chmods each child to its resolved mode, or records the child's mode from disk when no rule or default gives one.owninpkgforge.cfgand--own PREFIX(repeatable) name the directories a package owns: recording an entry at or below one also records each missing ancestor directory down from the prefix.dbdump --dirs owned(Project.dump(dirs=),own=ofpkgforge.dbdump.dump) keeps only the directories at or below an owned prefix, so a manifest claims no directory the distribution owns.- Entry kinds and file capabilities: an entry records a
kind(file,config,config-noreplace,doc,licenseorghost; unset isfile) andcaps(onesetcapclause such ascap_net_bind_service=ep), set withinstall -k/--kindand--caps,mkdir -k,symlink -k,[[rules]]and[[stage]].pkgforge.entrygainsFileKind,normalize_kindandnormalize_caps.rpmspecfilesstarts a line with%config,%config(noreplace),%doc,%licenseor%ghostand writes%caps(CAPS);dbdump -f debianwritesP.conffiles(config files outside/etc),not-installed(ghost files, sodh_missing --fail-missingaccepts them), asetcapline per capability inP.postinst, and${pkgforge:Depends}inP.substvarsnaminglibcap2-bin. Ghost entries are left out ofinstall,dirsandfixperms. - An entry records the
packageit ships in:-P/--package NAMEoninstall,mkdirandsymlink,packagein[[rules]]and[[stage]], else the[package]name. A name the configuration does not declare exits 2 before anything is staged;pkgforge.entry.normalize_packagechecks one. [[users]]and[[groups]]declare the accounts the package's files belong to (UserAccount,GroupAccount):name, and for a userdescription,home,shelland supplementarygroups;create = falsemarks an account that exists on the target.pkgforge.entry.BASE_USERSandBASE_GROUPSare Debian's base-passwd names, which need no declaration.pkgforge sysfiles(Project.sysfiles) writes and records/usr/lib/sysusers.d/NAME.conffor the accounts the package creates and/usr/lib/tmpfiles.d/NAME.conffor its ghost directories;stageruns it. rpm 4.19+ and debhelper act on the files when the package is installed, so a file owned by a service user is staged without that user existing at build time.dbdump -f debianleaves an entry owned by a created account out offixperms:P.postinstapplies it with a guardeddpkg-statoverride --update --add(an administrator's override is kept) andP.postrmremoves it on purge. The Debian recipes needdh $@ --with installsysusers(debhelper 13.10 or later).pkgforge stage [--init](Project.stage) runs the[[stage]]steps in order: install a source at a destination, or record a directory, an empty file or a symlink. Every source is checked before the first step runs.pkgforge mkdir [-p] DEST...andpkgforge symlink TARGET LINK(Project.mkdir,Project.symlink) record a directory or a symbolic link that has no source.pkgforge verify [-X STMT]... [-f text|json](Project.verify, returningVerifyIssuetuples) checks the build root against the file DB beforerpmbuildordh_missingruns: a file or symlink no entry records (unrecorded), an entry whose path is gone (missing, never for aghost), a changed type (type) and a retargeted symlink (target). It exits 1 when it finds any, and 2 with no file DB or no build root.dbdump -f rpmspec(RPMSpec) andrpmspec-pre419(RPMSpecPre419, for rpm older than 4.19) write the whole spec frompkgforge.cfg: the preamble, a%package -nblock per[[packages]]element, a%installthat runspkgforge stage --initand writes one%fileslist per package, the%postthat creates thetmpfiles.dpaths (and, below rpm 4.19, the%prethat creates the accounts),%systemd_post,%systemd_preunand%systemd_postun_with_restartfor the systemd units a package records (*.service,*.socket,*.timerand*.pathdirectly in/usr/lib/systemd/system; they expand to nothing without systemd-rpm-macros, EL8: systemd), and one%files -fper package. Build it withrpmbuild -bb --define "pkgforge_config PATH" NAME.spec. It needs[package]name,version,summaryandlicenseand asummaryper subpackage, and exits 2 naming what is missing; a description line whose first non-blank character is%or#exits 2, since rpm would read it as a section, a conditional or a comment.dbdump -f debian --skeleton(skeleton=TrueofProject.dumpandpkgforge.dbdump.dump) also writescontrol,changelog,rules(adebian/tmprecipe that runspkgforge stageand the dump) andsource/format, withBuild-Depends: debhelper-compat (= 13), dh-sequence-installsysusersand a stanza per package. It needs a directory OUTPUT, the path ofpkgforge.cfgand[package]version,summaryandmaintainer; a${NAME}in thesummary,description,homepageormaintainerof a package exits 2, since dpkg-gencontrol would replace it and nothing escapes it.- A spec or skeleton file that exists is kept, with a WARNING, so edits survive
regenerating;
--force(force=True) replaces it.--forcewith a format that writes no such file, and--skeletonwith a format that has no skeleton or without a directory OUTPUT, exit 2.DumpFormat.SKELETONandDumpFormat.KEEP_EXISTINGmark such a format. pkgforge package -f FORMAT [-o DIR] [--arch NAME] [--compression KIND](Project.package,pkgforge.package.build) builds one package per declared package straight from the file DB and the build root, with the DB's modes and owner names, and prints each path:deb:NAME_VERSION[-RELEASE]_ARCH.deb, withoutdpkg-deb, debhelper or fakeroot, with the control file,conffiles,md5sumsand thepostinst,prermandpostrmscripts debhelper would build from the samepkgforge.cfg(sysusers, tmpfiles, the systemd units,dpkg-statoverridefor an account created at install time,setcap);--compressionxz(default),gz,zstornone. A compressed member over 9999999999 bytes, more than an ar header holds, exits 1.rpm:NAME-VERSION-RELEASE.ARCH.rpmthroughrpmbuild, from the specdbdump -f rpmspecwrites (rpmspec-pre419below rpm 4.19);--archis passed as--target, and without the systemd rpm macros it logs a warning and leaves the unit scriptlets out.arch:NAME-VERSION-PKGREL-ARCH.pkg.tar.zst(or.xz,.gz), withoutmakepkgor fakeroot. pacman ignores the owner names of a tar, so every header isroot:rootand.INSTALLcreates the accounts and applies each other owner, group, setuid mode and capability.apk:NAME-VERSION-rREL.apk(apk v2, unsigned:apk add --allow-untrusted), withoutabuild; its scripts create the accounts and ghost directories and set capabilities.tar:NAME-VERSION[-RELEASE].tar[.gz|.xz|.zst],--compressiongz,xz,zstornone.
A recorded path that is missing or of another type, or whose parent is a
symlink leading outside the build root, exits 2 before anything is written.
The files of a hardlinked set ship as one file and hardlinks to it. Payloads
are streamed, so memory does not grow with them (about 100 MiB of compressor
state for xz or zstd). A build is all or nothing: files are written beside
their final names and renamed into place once every package is built, and a
failure (Ctrl-C included) removes what the build created. With
SOURCE_DATE_EPOCH set, two deb, arch, apk or tar builds of one build
root are byte-identical. A format is a PackageFormat subclass, selected by
NAME or advertised in the pkgforge.package_formats entry-point group, that
writes through BuildContext.writer, a PackageWriter.
- A zstd extra (pip install 'pkgforge[zstd]') provides zstd compression
below Python 3.14, which has compression.zstd.
- pkgforge.Project, the Python API for build scripts: Project(buildroot,
db=None, *, db_format=None, config=None, config_path=None) with init,
load, entries, compact, install, scan, mkdir, symlink, stage,
sysfiles, dump, verify and package. It does what the commands do,
takes and returns plain Python values, prints nothing and returns the records
it wrote (with no file DB, those are the only result). install(type=) takes
a FileType and exclude= statement text. pkgforge.FileRecords and
pkgforge.StrPath are its types.
- pkgforge.dbdump.dump(db, fmt, output=None, *, exclude=(), own=None,
config=None, config_path=None, skeleton=False, force=False) renders an
already-loaded DB; pkgforge.dbdump.Rendered is the result type and
pkgforge.dbdump.DumpContext the context a format renders with.
- pkgforge.db.jsonl_line(path, entry) and pkgforge.db.parse_jsonl(text,
source="text") write and read the JSON Lines records the jsonl backend
stores.
- dbdump --db - reads the file DB as JSON Lines from standard input, so
pkgforge install --db - ... | pkgforge dbdump --db - -f rpm works. Stdin
must be a pipe, a socket or a regular file: a terminal, a closed stdin or a
device such as /dev/null exits 2 instead of dumping an empty manifest.
compact and scan --drop-stale refuse --db - (exit 2).
- Third-party DB backends, dump formats and package formats are found through
the entry-point groups pkgforge.dbs, pkgforge.dump_formats and
pkgforge.package_formats (name = module:Class): installing the package is
enough for --db-format NAME, a --db suffix, dbdump -f NAME and package
-f NAME to select it. names() includes advertised names without loading
them.
- pkgforge.testing: check_db_provider(cls, directory) and
check_dump_format(cls, directory) run the checks every DB backend and dump
format must pass, so a third-party class is tested against the same contract
as the built-in ones. DB_PROVIDER_CHECKS and DUMP_FORMAT_CHECKS hold the
individual checks.
- --debug prints the traceback of an error before its one-line
pkgforge: error: message, like PKGFORGE_TRACEBACK.
- The example examples/pkgforge.cfg declares two packages, hello and
hello-doc; examples/hello.spec and examples/debian/ are generated from
it. A guide page, "From pkgforge.cfg to packages", builds them with
rpmbuild, dpkg-buildpackage and pkgforge package, and tabulates what
each format needs, writes and runs at install time.
- --mode, --owner, --group, --type and --dir-mode take the next word
as their value even when it is --, so -o -- t /l reads the owner from
disk like --owner=--.
- The agent help (AGENT_HELP=1 pkgforge --help) lists the exit codes 0, 1, 2
and 130, and a failed MCP tool call reports its code, so verify with
differences ends as exit code: 1.
Changed
- Requires
duho>=0.7.8,<0.8(was>=0.6.0,<0.7) andanycfg>=0.15.1,<0.16, which readspkgforge.cfgand is imported only when a file is read. PyYAML is the optionalyamlextra instead of a core dependency:pip install 'pkgforge[yaml]'to keep ayamlDB, which otherwise exits 1 with a message naming the extra. The command line keeps its flags, its--helptext and its 0, 1 and 2 exit statuses. - Ctrl-C ends the command with status 130 and no output, or raises
KeyboardInterruptunderPKGFORGE_TRACEBACK. PKGFORGE_TRACEBACKreplacesDUHO_TRACEBACKas the switch that prints the traceback before thepkgforge: error:line, andDUHO_TRACEBACKhas no effect on pkgforge: setPKGFORGE_TRACEBACK=1where you setDUHO_TRACEBACK=1. A closed output pipe is silent with status 1 either way.- A bad
-Ovalue reports duho's usage message, which still readsexpected KEY=VALUE, got 'novalue'. - The console script is
pkgforge.cli:main(waspkgforge:main);python -m pkgforgeruns the same.pkgforge.cliis the command line, not library API: from Python, usepkgforge.Project. - The package root exports the library names only:
Project,VerifyIssue,FileEntry,FileType,FileEntries,FileRecords,StrPath,DbProvider,open_db,DumpFormat,PackageFormat, the exceptions and__version__. Importapply_entry,entry_from_path,normalize_modeandresolve_entryfrompkgforge.entry.import pkgforgeloads neitherduhonor the command line. - Every exception class is in
pkgforge.exceptions(andpkgforge), not inpkgforge.db,pkgforge.dbdumporpkgforge.exclude.ExcludeSyntaxErroris aUsageErroronly (it was also anargparse.ArgumentTypeError). - Classes with an acronym in the name spell it in capitals:
JsonlDbisJSONLinesDb,YamlDbYAMLDb,SqliteDbSQLiteDb,RpmSpecFilesRPMSpecFiles,RpmSpecFilesPre419RPMSpecFilesPre419, and thedebianformat classDebianisDebianFiles. Format and DB names, aliases and suffixes are the same.pkgforge.dbdump.Entriesispkgforge.FileEntries. PathMatchStmtsubclassesargparse.Namespace(wasduho.NS), andPathMatchtakesPathMatch(stmts, root=None): theinstallrootargument is gone, and a path outsiderootraisesValueError.- On
installandscan, an absolute-Xpattern is relative to the input, liketar --exclude, where 0.2.0 matched it against the install path:install -X /tmp ./build /opt/appexcludes./build/tmp,install -X '/docs/**' app-1.0.tgz /optexcludes the archive'sdocs/members, andscan -X /cache /usr/share/appexcludes/usr/share/app/cache. A pattern that carries the destination (-X /opt/app/build/tmp) matches nothing: drop that prefix. A pattern without a leading/matches at any depth, anddbdumpmatches the DB paths, as in 0.2.0. -Xrejects a path in microseconds for glob patterns that took seconds (eight**segments, or many*in one segment), does not warn about&&,~~or||inside a[...]class, and matches faster over a large DB indbdump.- A dump format implements
render(entries, context),check_output(output)andwrite(rendered, output, *, force=False), where 0.2.0 hadDumpFormat.dump(entries, output, logger)andrender(entries).contextis apkgforge.dbdump.DumpContextholding the configuration, its path and the OUTPUT, and-(standard output) is handled by thedbdumpcommand. A format of your own moves itsdumpcode towrite, takes thecontextargument, acceptsforceand drops its-case;PerEntryFormatgroups the entries per package and itscheck_outputaccepts a directory. - A DB backend or dump format whose
NAME, alias or suffix another class holds raisesValueErrorwhen the class is created and is not registered, where it replaced the earlier class (for a suffix, the newer class won): rename the new one.DbProvider.compact()drops removed paths, and the contract suite fails a third-partycompactthat keeps them. - A file DB record has three more fields,
kind,capsandpackage, and thesqlitetable their columns. A 0.2.0 DB fails to load withmissing field kind; this DB was written by an older pkgforge: run initdb and record again: runpkgforge initdband stage again. - Every DB loader (
jsonl,yaml,sqlite) enforces one strict record schema and fails with onepkgforge: error: <file>: <path>: ...line, status 1, where 0.2.0 coerced or defaulted: a number for a mode, owner or group, a missing field, anulltype and an unknown field are refused. Re-record the DB. - The file DB format is
--db-format, else the--dbsuffix, elsejsonl; 0.2.0 also read an unrecognized text file as YAML. A file whose content is not in that format fails to load with an error that names the format and suggests--db-format: pass--db-formatfor a file with another suffix. dbdumpandcompactexit 2 when no DB is configured or--dbnames a file that does not exist (no file DB at PATH; run pkgforge initdb), where they logged a WARNING and exited 0. An existing empty DB exits 0 with a WARNING. Runpkgforge initdbfirst, or pass the right path.dbdumprefuses a DB entry that is not well formed, with onepkgforge: error:line naming the entry, status 2, and writes nothing: a key that is not an absolute path with no./../empty segment, atypeother thanfile,directoryorsymlink, a mode that is not 1-4 octal digits,-or--, an owner or group that is not an account name,-or--, and akind,capsorpackagevalue that is not valid. Fix or re-record the entry.installandscanrefuse an--owner/--groupthat is not an account name ([A-Za-z_][A-Za-z0-9_.-]*,-or--), with status 2 before anything is staged or recorded: a numeric owner such as1000is refused, so use the account name.- An owner or group outside Debian's base-passwd set must be declared in
[[users]]or[[groups]]ofpkgforge.cfg, or recording it is exit 2; 0.2.0 recorded any name. The check covers-o/-g,[defaults],[[rules]],[[stage]]and the names that--reads from disk, soscan --owner=--over files owned by an ordinary account needs that account declared (create = false). installandscanwith no--db(or--db -) print their JSON Lines records once the operation has succeeded, instead of one by one as they are written.rpmspecis the name of the spec format, where 0.2.0 took it as an alias ofrpmspecfiles: use-f rpmspecfiles(or-f rpm) for the%fileslines. A per-entry format (rpmspecfiles,rpmspecfiles-pre419) writes the entries of one package into a file or-, and refuses a DB holding entries of two packages (exit 2); given an existing directory it writesfiles-PACKAGE.txtfor every package ofpkgforge.cfg.dbdump -f debianwrites one set of files per package, named for it, where 0.2.0 wrote a namelessinstall,dirs,permissionsandfixperms:PKG.install,PKG.dirs,PKG.conffiles,PKG.postinst,PKG.postrmandPKG.substvarsfor[package]and each[[packages]]name, and oncenot-installedandfixperms. It needs[package] nameinpkgforge.cfg(exit 2 without it). Delete the nameless files the dump wrote intodebian/: debhelper readsdebian/PKG.NAMEfirst and ignores the nameless file beside it, and compat 15 refuses it for a source with several binary packages.fixpermstakes the debian directory, not the package's: runsh debian/fixperms debian.dbdump -f debianrefuses, before it writes anything (exit 2), to replace a hand-written debhelper file: an existinginstall,dirs,not-installed,postinst,postrmorfixpermswithout the line# Generated by pkgforge dbdump -f debian, a namelessinstall,dirs,conffiles,postinstorpostrmthat debhelper would ignore, and aPKG.conffileslisting a path the dump would drop. Move such lines intopkgforge.cfgor the package-named file and delete the stray one.
Removed
pkgforge.main,pkgforge.commandand thepkgforge.install,pkgforge.scan,pkgforge.initdbandpkgforge.compactmodules, withPkgForgeCmd,PkgForge,FileEntryArgs,FileEntryBaseArgs,entry_from_args,parsepath,ExcludeArgsandpkgforge.dbdump.DbDump. The commands are inpkgforge.cli(not library API) and the operations areProjectmethods: replacepkgforge.main(argv)withpkgforge.cli.main(argv), or callProjectfrom a build script.pkgforge.errors: import the exceptions frompkgforge.exceptionsorpkgforge.- The
pkgforge.install.archive,decompress,staging,transfer,recordand_toolmodules and theInstallstaging methods (_resolve,_stageand the like); staging isProject.install. - The backend submodules
pkgforge.db.jsonl,pkgforge.db.yaml,pkgforge.db.sqlite,pkgforge.dbdump.rpmandpkgforge.dbdump.debian: import the classes frompkgforge.dbandpkgforge.dbdump. DbProvider.lookup(name)andDumpFormat.lookup(name): callget(name).DumpFormat.dump(entries, output, logger): a format implementsrender,check_outputandwrite(see Changed).- The
FileEntry.from_args,from_path,resolve_forandapplyclass aliases: callentry_from_path,resolve_entryandapply_entry. - The
List[str]form ofnormalize_modeandnormalize_name, and anintmode innormalize_mode: both take astr, and an int raisesUsageError. FileType._AUTO: use theAUTOstring.list(FileType)isfile,directory,symlink.PathTest.GENERATORS,PathTest.factory,FilterTestRe,filetypetestandmetatestinpkgforge.exclude, andNAME_PATTERN,MODE_PATTERN,CONTROL_PATTERNandRPM_EXPANSION_PATTERNinpkgforge.entry.PathMatch.unreachable(),log_unreachable()and the warning that an absolute-Xpattern could match no install path.- The
RPMSpecFiles._quoteandRPMSpecFilesPre419._quoteclass hooks, whichrendernever called. - The
sniff_formatfunction, theDbProvider.sniffhook and thefor_readargument ofopen_db:open_db(path, fmt=None)resolves by format name or suffix only (see Changed). dbdump --stdin: use--db -.scan --type/-t, which was hidden and ignored:scanexits 2 for it.- The
--decompresstool-name aliasesgzip,gunzip,xz,unxz,bzip2,bunzip2,zstd,unzstd,lzmaandunlzma: a kind is exactlygz,xz,bz2,zstorlzma, in any case. - The archive destination-name fallback that cut a name at its last
tarorisosegment;.tar.zst,.tzst,.tar.lz4,.tar.lzma,.tlz,.tar.lzand.cpioare listed suffixes. - The
permissionsartifact ofdbdump -f debian(and its section indbdump -f debian -): nothing consumed it, andfixpermsapplies the same pins. meta.rpmprefix: a-O rpmprefix=...or ametaargument holding the key is refused (exit 2). Use-k/--kindand--caps, orkindandcapsinpkgforge.cfg.- The import-time binding of
PKGFORGE_DB,PKGFORGE_DB_FORMAT,PKGFORGE_ROOT,PKGFORGE_INSTALL_METHODandPKGFORGE_INSTALL_RECORD_TREEas class defaults:pkgforge.cli.main()reads them when it parses, and a command class built directly in Python ignores the environment.
Fixed
- On Windows, a drive-qualified path (
mkdir D:/x) raisedValueError: Paths don't have the same drive. It is a usage error, exit 2, saying the path resolves outside--buildroot. install --method moveof a directory onto another filesystem failed withInvalid cross-device link(and a rollback could fail the same way). It moves the tree file by file, keeping modes, mtimes and symlinks, and removes the source.install --remove-source --method linkkept a file source and removed a directory source. Both are removed; the staged hardlinks keep the data. The source is kept only when it is the destination itself.- A failed
install -pleft the parent directories it had created. It removes them before exiting with status 1; directories that already existed are never touched. - An
Install(source=["/x"], destination="/usr/bin", ...)()command built from Python withstrpaths raisedAttributeError.strand path-like values are accepted, and anything else raisesUsageError. scan --drop-staleinto asqliteDB committed once per removed row; it holds one connection for all its removals.- A
%(command)or a newline in a recorded owner, group or mode reached rpmbuild's%fileslist, where it ran a command or injected a line;dbdumprefuses the entry (exit 2). - A DB key such as
/../../x, or a relative one, reached thedebianfixpermsscript, where it chmod-ed or chown-ed a path outside the package directory;dbdumprefuses it (exit 2). - An entry with no
type(or-, or an unknown value) rendered as a plain%filespath, which made rpm ship the whole subtree;dbdumprefuses it. - A corrupt or truncated
.tar,.tar.gz,.tar.xzor.tar.bz2archive ended in a traceback. It is onepkgforge: error: cannot extract ...line, status 1. - A failing
bsdtaror decompressor (install -x) ended in aCalledProcessErrortraceback. It is onepkgforge: error: <tool> failed (exit N): <its last message>line, status 1, and the tool reads standard input only when the source is-. - A SQLite file DB that cannot be opened, written or read (an unwritable
directory, a locked or non-SQLite file, a schema mismatch, a corrupt
meta_json) ended in a traceback. It is onepkgforge: error: <path>: ...line, status 1. - A JSON Lines or YAML file DB nested too deeply to parse ended in a
RecursionErrortraceback. It is a one-line error naming the file. - Under MCP,
dbdump --stdinoverwrote an existing OUTPUT with an empty manifest andinstall -failed with anAttributeError, both reading the protocol channel.dbdump --db -andinstall -return an error result there. - A text DB was split on every Unicode line break, so a record holding U+2028,
U+2029 or U+0085 failed to load, and a
.jsonlstarting with a byte-order mark was appended to as YAML. Records split on\nonly and a leading BOM is ignored. rpmspecfileswrote a symlink's recorded mode in%attr, and rpmbuild warnedExplicit %attr() mode not applicable to symlink; the mode is-.- A failed
dbdumpcould leave a truncated or mixed OUTPUT. Each file is written to a sibling.<name>.pkgforge-tmpand renamed into place once every write has succeeded.
0.2.0 - 2026-09-29
Upgrading from 0.1.2
pkgforge.commonand its dict-based registries are removed. ImportPkgForgeError/UsageErrorfrompkgforge.errors;FileType,FileEntry,FileEntryArgs,AUTO,DEFAULT,mode_to_octal,normalize_modeand the entry functions frompkgforge.entry;PkgForgeCmd,PkgForge,parsepathfrompkgforge.command(or straight frompkgforge, which still exports every name inpkgforge.__all__).pkgforge.db.register_provider()/PROVIDERS/SUFFIX_FORMATS/_SNIFFERSandpkgforge.dbdump.PER_ENTRY_FORMATS/MULTI_ARTIFACT_FORMATS/dump_formats()/PerEntryDumperare gone too -- subclassDbProvider/PerEntryFormat/MultiArtifactFormatwith your ownNAMEinstead, and look one up withDbProvider.lookup(name)/DumpFormat.lookup(name).pkgforge.dbdump.rpmspecfile()is gone -- callRpmSpecFiles().render_entry(path, entry).pkgforge.dbandpkgforge.dbdumpare now packages (one module per backend/format), and so ispkgforge.install(TAR_SUFFIXES/ARCHIVE_SUFFIXES/BSDTAR_EXTRACT_FLAGSmoved topkgforge.install.archive); only a directimport pkgforge.db/.dbdumpexpecting a plain module needs updating.DbProvider.formatis renamed toDbProvider.NAME.-X/--excludeon every command now matches the same install path (the/-rooted path the entry has, or will have, in the DB) instead of a per-command root. Rewrite an absolute pattern written for the old anchor as an install path (install -X /tmp build /opt/app->-X /opt/app/build/tmp) or make it relative;install/scanlog a warning for a pattern that can no longer match.PathMatchStmt.rebased()is removed -- passinstallroot=toPathMatchinstead.**in--excludenow matches any number of directories on every Python version; some patterns now exclude more than before -- review any-Xvalue containing**.installcopies file sources by default instead of hardlinking them, so-m/--chownno longer mutate the source and a build root on another filesystem no longer fails. For the old hardlink behavior, or to haveinstallconsume the source, pass--method linkor--method move(envPKGFORGE_INSTALL_METHOD).scan -mnow applies to regular files only; directories take the new--dir-mode. Add--dir-modeif you relied on-mfor directories.- Suffix stripping without
-Tis wider:.tgz,.tbz2,.tbz,.txz,.zip,.isoand upper-case suffixes now land atDESTINATION/app-1.0(previously only a literal.tar/.tar.gz/.tar.bz2/.tar.xzdot-segment was stripped). Update any script relying on the old paths. - Commands now log as
pkgforge.<command>(was<command>) -- update any--loglevelselector, e.g.--loglevel pkgforge.scan:WARNING. Usage, errors,--versionand shell completion now name the commandpkgforge(wasPkgForge) -- regenerate installed completions. install,scananddbdumpnow exit 2 (instead of a traceback, exit 1, or silently doing the wrong thing) for bad input: an out-of-buildroot DESTINATION/PATH, no--buildroot/PKGFORGE_ROOTat all (pass--buildroot /to do that on purpose), an unknown--db-format/-t/-mvalue, a malformed-Xstatement, or a baddbdump -f/OUTPUT combination. Scripts that depended on exit 1 or a traceback for these should check for exit 2 instead.dbdump -f debianoutput gains two new sections:dirs(dh_installdirslines) andfixperms(a script to run fromoverride_dh_fixperms, afterdh_fixperms, applying every pinned owner/group/mode). Wire both intodebian/rulesif your packaging pipeline parsesdbdump -f debian -directly.- Requires
duho>=0.6.0,<0.7(was>=0.5.0,<0.6) andpyyaml>=6.0,<7(was unbounded); building from source now requireshatchling>=1.27; thedocsextra is bounded to mkdocs 1.x/mkdocs-material 9.x/mkdocstrings below 2;twineandhatchlingare no longer in thedevextra (install them directly if you used them from it). DUHO_TRACEBACKnow follows duho's own boolean tokens:n/fturn it off (they used to turn it on).
Added
dbdump -f rpmspecfiles-pre419(aliasrpm-pre419) writes%fileslines for rpm older than 4.19 (measured on rpm 4.14, 4.16 and 4.18): the path is written completely unquoted, and a space, a glob character (* ? [ ] { }),%(any form), or a non-UTF-8 byte raises an error instead of packaging the wrong file. Choose it when the rpm that will build the package is older than 4.19; with rpm 4.19 or newer, userpmspecfiles(unchanged) instead.dbdump -facceptsrpm/rpmspecas aliases forrpmspecfiles, anddebas an alias fordebian.debianalso writesfixperms, a POSIXshscript that applies every pinned owner, group and mode to a package directory (sh debian/fixperms debian/<pkg>, afterdh_fixperms);dbdump -f debian -gains a# === fixperms ===section.permissionsis unchanged.dbdump --stdinreads the file DB as JSON Lines from standard input instead of--db, e.g.install ... | pkgforge dbdump --stdin -f FORMATto render a manifest without ever writing a DB file. No environment variable enables it, so an existing unattended script keeps its current behavior unchanged;--db -(orPKGFORGE_DB=-) is still write-only and reads as an empty DB.--stdinignores--db/--db-formatentirely. A terminal or already-closed stdin exits 2 immediately instead of waiting; otherwise it reads to EOF (blocking on a pipe that is never closed, same as any other stdin source). Only JSON Lines is accepted.scan --drop-stalerecords a removal for each DB entry below PATH whose file is gone from the build root, sodbdumpstops listing deleted files. Entries matching-Xare kept. Needs a--dbfile.install --record-tree(envPKGFORGE_INSTALL_RECORD_TREE) also records every path below a directory or archive DESTINATION that the DB does not already hold: owner/group/-Ofrom the install, mode and type from disk (symlinks-), honouring-X. It replaces thescan --missingstep after a tree install; without it an install still records only DESTINATION.installgains--method {copy,link,move}(envPKGFORGE_INSTALL_METHOD), applying only to a real file/directory source (a-stdin,-x, archive or symlink source is unaffected).copy(the default) is unchanged.linkhardlinks instead of copying -- fast and free of disk use, but the staged copy then shares the source's inode, so-m/-o/-g/--chownchange the source too; a file/entryos.linkcan't span falls back to a copy automatically.moveconsumes the source (a rename, or a copy-then-delete across filesystems for a file; a whole-tree rename when the destination doesn't exist yet and there's no-X, otherwise file-by-file, leaving excluded files behind); a failure applying/recording the entry afterwards restores the source where that's still a single reversible unit.--remove-sourceis a no-op withmove(the source is already gone).- The unattended-build guide shows how to install shell completion
(
--print-completion). - The command guide documents
-O/--meta KEY=VALUE(rpmprefix=%config(noreplace), a symlinktarget) andscan's entry options. PKGFORGE_MCP=stdioserves pkgforge's commands as MCP tools over stdio (from duho 0.6). Unset, nothing changes.- Python 3.14 classifier.
- The header documents
__version__. PkgForgeError,UsageError(aValueError). Errors now print onepkgforge: error: ...line and exit 2 for a usage mistake or 1 for a runtime failure, instead of a Python traceback (DUHO_TRACEBACK=1adds the traceback back); a closed output pipe now exits 1 silently. An unknown--chownowner or group now raisesUsageErrorinstead of a rawKeyError.PathMatch(stmts, root=None, installroot="/")andPathMatch.unreachable(): the former's newinstallrootkeyword names the install path a matched entry will have; the latter lists anchored statements that can never match under it.pkgforge.db.DbProviderandpkgforge.dbdump.DumpFormat(plus its two subclassesPerEntryFormatandMultiArtifactFormat) are now the documented extension points: add a backend or a dump format by subclassing one of these with your ownNAME(and, optionally,ALIASES/SUFFIXES/sniff) -- no registration call needed.pkgforge.dbdump.UnsupportedOutputError: raised for OUTPUT of the wrong shape for the chosen dump format (a file for a multi-artifact format, or an existing directory for a per-entry one). Also aUsageError(exit 2, unchanged) and, per its name, aNotImplementedError.pkgforge.dbdump.DumpError, raised for a DB entry a dump format's own tooling cannot represent.pkgforge.DbError: a malformedjsonl/yamlfile DB fails with one clear line naming the file (and, forjsonl, the line) instead of a traceback.pkgforge.exclude.ExcludeSyntaxError, aUsageErrorandargparse.ArgumentTypeError, raised for a malformed--excludestatement.entry_from_args,entry_from_path,resolve_entry,apply_entry, typed forms of theFileEntryhelpers (entries are dicts;.resolve_for/.applyon an entry never worked).normalize_mode(): normalizes amodevalue (anint, an octal string, or the-/--/autosentinels) to the octal permission string the file DB stores.pkgforge.DbProvider.batch(): an optional context manager a backend can override to batch many writes; the default does nothing, so existing and third-party providers are unaffected.scaninto asqliteDB now uses it: one connection, committed every 1000 rows (and once more at the end), instead of connecting, creating the schema and committing once per file.
Changed
pkgforge.dbandpkgforge.dbdumpare now packages, one module per backend/format (db/{jsonl,yaml,sqlite}.py,dbdump/{rpm,debian}.py), instead of one flat module each. Import frompkgforge.db/pkgforge.dbdumpas before; only a directimport pkgforge.db(or.dbdump) expecting a plain module, rather than a package, would need updating.DbProvider.formatis renamed toDbProvider.NAME.pkgforge.installis a package:TAR_SUFFIXES,ARCHIVE_SUFFIXESandBSDTAR_EXTRACT_FLAGSmoved topkgforge.install.archive;pkgforge.install.Installis unchanged.- The API reference has one page per module, covers every public name and no longer shows private helpers.
-Xmatches the install path — the/-rooted path the entry has in the DB — on every command.installanchored an absolute pattern at the source directory andscanat<buildroot>/PATH; a relative pattern now also matches DESTINATION/PATH segments above the copied or scanned tree, asdbdumpalready did. Rewrite such patterns as install paths (install -X /tmp build /opt/app->-X /opt/app/build/tmp) or as relative ones; an absolute pattern that can no longer match logs a warning.install,scananddbdumptake--exclude/-Xfrom a sharedpkgforge.exclude.ExcludeArgsbase instead of each declaring it separately; its position ininstall --helpmoves earlier (right after--buildroot), with no other visible change.install -Xwith only file or symlink sources logs a warning (nothing to filter); a directory or archive source is always filtered.scanlogs one INFO summary; per-pathUpdating file entry for:lines need-v.- Commands log as
pkgforge.<command>(was<command>, e.g.scan). Use the new name in--loglevel, e.g.--loglevel pkgforge.scan:WARNING; in Python,logging.getLogger("pkgforge")controls all of them. scan -mapplies to regular files only. Directories take the new--dir-mode(default: from disk with--mode=--, else-); symlinks always record mode-. If you relied on-mfor directories, add--dir-mode.scanandinstalllook up user and group names only for fields set to--, and cache each lookup for the process's life.app-1.0.tgz,.tbz2,.tbz,.txz,.zip,.isoand upper-case suffixes installed without-Tnow land atDESTINATION/app-1.0(previously only a literal.tar/.tar.gz/.tar.bz2/.tar.xzdot-segment was stripped); a directory source keeps its full name unchanged (conf.tar.dstaysconf.tar.d, neverconf). Update any script relying on the old paths.- A tar archive writing through any symlink, even one inside the
destination, is refused (exit 1), as
bsdtaralready did; a hardlink member naming another archive member by an absolute-looking path (e.g./a) links to that member, never to a real host path that happens to exist there; escaping members or links, and device nodes or FIFOs, exit 1 with one message, not a traceback. - The
yamlbackend uses PyYAML's libyaml-backed loader/dumper when the installed PyYAML build has them; the on-disk file format is unchanged. dbdumpwrites entries sorted by path, so a staged tree gives byte-identical manifests on any filesystem and backend (was insertion order).compact(jsonl/yaml) now replaces the DB with a new inode (a temp file renamed into place) instead of rewriting it in place. A--dbhardlinked elsewhere is detached from that hardlink by the next compact; a symlinked--dbkeeps its link (the link's target is what gets replaced), and the file's mode (and owner/group, best-effort) are carried over unchanged.- The documentation site is now rebuilt from
mainwhenever the docs change, and from the release tag after each final release. A pre-release tag leaves it unchanged. - Pre-release tags (
vX.Y.Z-rc.N) now produce a GitHub pre-release only and are no longer uploaded to PyPI. Install a pre-release from the wheel attached to its GitHub release. - Requires
duho>=0.6.0,<0.7(was>=0.5.0,<0.6).DUHO_TRACEBACKnow follows duho's boolean tokens:nandfturn it off (they used to turn it on).--loglevelaccepts[NAME:]LEVEL[,...]and rejects a malformed value with exit 2;-v/-qgain--verbose/--quiet; log output is colored only on a terminal and never whenNO_COLORis set. pyyamlis declared as>=6.0,<7; it was unbounded.- Building from source requires
hatchling1.27 or later. - The
docsextra is bounded to mkdocs 1.x, mkdocs-material 9.x and mkdocstrings below 2. twineandhatchlingare no longer in thedevextra; install them directly if you used them from it.- The
devextra installsblackon Python 3.10 and later. - The sdist no longer includes
benchmarks/. benchmarks/run.pytimes thescancommand end to end per DB backend (scan.cmd_jsonl,scan.cmd_yaml,scan.cmd_sqlite,scan.cmd_auto_owner).scan.walk, which timed onlyos.walk, is renamedfs.walk_baseline. The result schema is documented inbenchmarks/README.md.
Removed
pkgforge.common: importPkgForgeError/UsageErrorfrompkgforge.errors;FileType,FileEntry,FileEntryArgs,AUTO,DEFAULT,mode_to_octal,normalize_modeand the entry functions frompkgforge.entry;PkgForgeCmd,PkgForge,parsepathfrompkgforge.command. Names inpkgforge.__all__still import frompkgforge.pkgforge.db.register_provider(),PROVIDERS,SUFFIX_FORMATSand_SNIFFERS: subclassDbProviderwith your ownNAME(and, optionally,ALIASES/SUFFIXES/sniff) instead; look one up by name withDbProvider.lookup(name)(list every registered name withDbProvider.names()).pkgforge.dbdump.PER_ENTRY_FORMATS,MULTI_ARTIFACT_FORMATS,dump_formats()andPerEntryDumper: subclassPerEntryFormatorMultiArtifactFormatinstead; look one up withDumpFormat.lookup(name)(list every registered name withDumpFormat.names()).pkgforge.dbdump.rpmspecfile(): useRpmSpecFiles().render_entry(path, entry)instead (from pkgforge.dbdump import RpmSpecFiles).PathMatchStmt.rebased(): passinstallroot=toPathMatchinstead.
Fixed
- The formats guide now states, measured, what rpm older than 4.19 does
with a
rpmspecfilesline: adquote/backslash/non-UTF-8 name fails the build, astar/qmarkname silently packages an extra sibling file, and abracket/bracename silently packages the wrong file -- it previously only saidrpmspecfiles"targets rpm 4.19+" with no detail below that version. - The formats guide's
override_dh_fixpermsexample readpermissionsfields in the wrong order (it passed the group tochmod); it now runs the generatedfixpermsscript. - The docs no longer call
debian'spermissionsdpkg-statoverrideinput (that tool takesuser group mode pathand rejects-); they now say how to apply it from anoverride_dh_fixpermstarget, and thatdh_install's sources needPKGFORGE_ROOT=debian/tmpor--sourcedir=$(PKGFORGE_ROOT). - The README quick start, the docs landing page and the unattended guide no
longer run
scan --missingover/usror/etc: every directory a scan walks becomes an RPM%dirownership claim, and Fedora/RHEL'sfilesystempackage ships/usr/binas0555, so the built RPM conflicted with it andrpm -Urefused to install. Passscanonly a directory your own package owns. - README links and badges work on PyPI and in the installed
pkgforge/README.md. - The wheel and sdist never include files named
*.local.*orCLAUDE*, even when built from a tree without.gitignore. examples/stage_and_package.shis executable, so./examples/stage_and_package.shruns in a fresh checkout.examples/stage_and_package.shstages its tree at/usr/share/tooland records its files.dbdump ... -writes throughsys.stdoutinstead of a raw file descriptor: it now works whensys.stdoutis redirected (or otherwise not backed by a real file descriptor), and keeps its place after text the caller already printed but had not yet flushed.dbdumpchecks-f/OUTPUT before reading the DB: an unknown format, a file OUTPUT fordebian, or a directory OUTPUT forrpmspecfilesnow exits 2 with one line naming the problem (was exit 1 or a raw traceback, possibly after loading the whole DB).rpmspecfilesescapes\and"for rpm 4.19+ and writes non-ASCII names as UTF-8 (was\uXXXX, which rpmbuild could not find). A control character in a path stopsdbdumpwith an error naming the problem.debianinstalllines are escaped fordh_install(\ * ? [ ] { }and a leading#get a backslash; space becomes${Space}and a literal${in a destination directory becomes${Dollar}{; both need debhelper compat 13): before this, a space aborteddh_install, a literal${aborted it too, and glob characters shipped the wrong files. A control character in a path, or whitespace in a mode/owner/group, now stopsdbdumpwith an error. Non-UTF-8 names are written as their original bytes (wasUnicodeEncodeError).debianalso writesdirs(dh_installdirslines, one per directory entry), so an empty directory recorded withinstall -dreaches the package (previously onlyrpmspecfiles'%dircovered it).dbdump -f debian DIRnow also writes (and replaces)DIR/dirs, and-gains a# === dirs ===section.debian'sinstalldestination is split with POSIX path rules on every platform: on Windows, a source name containing a literal backslash (a valid POSIX filename character) was previously split in the wrong place, since the native path module there also treats a backslash as a separator.scanno longer records the file DB (or a SQLite DB's-journal/-wal/-shmsidecars) found inside the scanned tree, and warns once instead; other files there, such as an earlierdbdumpoutput, are still recorded -- keep the DB and any dump output outside--buildroot.scanof a PATH that does not exist under the build root exits 2 with one message, instead of a rawFileNotFoundErrortraceback.scanrecords a symlink PATH as onesymlinkentry instead of walking its target: previously, scanning a link to a directory (an absolute target included, e.g./etc) recorded the target's own contents under the link's path -- for an absolute target, the build host's filesystem.PATH /is unaffected and is still walked, even through a symlinked--buildroot.install -Xfilters an archive source's members like a directory copy (every member used to be extracted regardless of-X): excluded members and directories never reach DESTINATION. An archive holding a device node, FIFO or socket is still refused even when-Xmatches it.**in--excludematches any number of directories on every Python version:/**/*.pycexcludes.pycfiles at any depth,/opt/app/**excludes everything below/opt/appbut not/opt/appitself. Some patterns now exclude more than before; review any-Xvalue containing**.install -Xandscan -Xread file metadata only for a statement that actually has an inline test ((?type:...)/(?meta:...)), so a glob-only-X '*.fifo'now skips a FIFO or socket instead of failing on it; a special filescancannot record any other way now stops it with a one-line error naming the path and the-Xremedy, instead of a traceback or a silently truncated DB.(?meta:k=v)ininstall -Xandscan -Xnow sees that run's-Ovalues; it never matched there before, and(?!meta:k=v)excluded every matching path regardless of-O.PathMatch.match(path, entry, **overrides)no longer writes the**overridesinto the caller'sentrydict (reachable only from the Python API, e.g. filtering a loaded DB indbdump).scan -Xdoes not descend into an excluded directory, so its contents are no longer recorded (as withinstall). To leave only the directory out of a manifest, exclude it indbdumpinstead:dbdump -X '(?type:directory)/usr'.- A malformed
-Xstatement (an unknown inline test, an invalid(?type:...)/(?meta:...)argument, or an unterminated(?...) now exits 2 naming the problem, instead of a traceback (unknown test) or silently becoming a literal glob that excluded nothing (an unterminated test).-O/--metawithout=reportsexpected KEY=VALUE, got '...'instead ofinvalid <lambda> value. Every command's--helpnow shows the-Xgrammar. - A directory install whose source contains the build root, destination or
file DB skips them (their parents are created empty) instead of
recursing into
RecursionError. - Tar archives holding absolute symlinks, or symlinks that climb above the
destination, extract with their targets kept exactly as written (instead
of
tarfile.AbsoluteLinkError/LinkOutsideDestinationError), and re-extract cleanly over an earlier run instead of failing. - Re-running a directory install whose source holds symlinks no longer
fails with
shutil.Error; a stale symlink left at the destination (including a directory-source's own symlink retargeted between runs) is replaced instead, and a regular-file source is never written through a leftover destination symlink. installandscanexit 2 when DESTINATION or PATH leaves the build root through a..that climbs above it, or a symlinked path component leading outside it, instead of writing or recording outside--buildroot. An in-root..is normalized (/usr/share/../lib/xrecords/usr/lib/x).- With no
--buildrootorPKGFORGE_ROOT,installandscanrun from/exit 2 instead of using the live filesystem as the build root; pass--buildroot /(orPKGFORGE_ROOT=/) to do that on purpose. - A file install no longer fails on macOS for a source with a BSD file flag set (e.g. a system binary), where copying the source's flags along with its content used to raise a permission error even though the source is only read. The staged copy carries the source's content, permission bits and modification time; it never carries file flags or extended attributes (e.g. an SELinux label).
-dwith-t/--typeexits 2 instead of-dsilently overriding-tand turning a file source into a bsdtar-style directory extraction.installchecks--chown's owner/group names, the--dbdirectory and (without-p) the destination directory before staging (exit 2 with nothing on disk); refuses--remove-sourceup front (exit 2) when a directory source contains the resolved destination or the--dbfile; and removes the source only after the entry is recorded, never when it is itself the staged destination (which is now skipped with a warning instead of deleting the only copy).- A failed install leaves no empty or partial file (or extracted directory)
behind, and keeps an earlier staged copy at that destination exactly as
it was, instead of truncating or clobbering it. Re-running a symlink
install now replaces the link (including a dangling in-place link, and a
target retargeted between runs) instead of failing with
FileExistsError; a stale host symlink at the destination is replaced by the real staged file instead of being silently kept. - Sources resolving to one non-directory destination in a multi-source
installexit 2 before staging instead of the last one silently overwriting the earlier ones (directory and archive sources sharing a destination still merge, as before). A symlink source's recordedmeta.targetno longer leaks into every entry recorded after it, in the same install, in a later Python-API call, or in a later CLI invocation built from the same process. - A
-source on a terminal or closed stdin, or a terminal character-device path, exits 2 instead of staging an empty file or waiting;installkeeps stdin open instead of closing fd 0, and only one-source is allowed per invocation.<(cmd),/dev/stdinand named pipes now stage as files (previously a dangling symlink to the pipe, or a crash for a plain FIFO). -x/--decompresstakesgz,xz,bz2,zst,lzmaor a decompressor tool name (gzip/gunzip,xz/unxz,bzip2/bunzip2,zstd/unzstd,lzma/unlzma), matched case-insensitively; any other kind, or a bare-xon a source suffix that names none of them, exits 2 before anything is staged, instead of silently compressing the file, leaving the old suffix on the destination, or running an arbitrary word onPATHas a command.-x gzipnow decompresses (it used to compress); a resolved kind whose tool is missing fromPATHis named in one error line.Install(...)built directly from Python (not through the CLI parser) and given nodecompress=argument now stages the source unchanged, instead of silently inferring a decompressor from its suffix and running it as a subprocess (which, for a.shsource, executed it).installcopies file sources instead of hardlinking them:-m/--chownno longer change the source, and a build root on another filesystem (e.g. a tmpfs/tmp) or a source the caller doesn't own no longer fails.-o --/-g --(AUTO) now record the staged copy's owner, not the source's.install -mon a symlink, and anyinstall -mon glibc older than 2.32 (e.g. RHEL/Rocky 8), no longer fails withNotImplementedError. A symlink's mode is recorded but never applied on disk (Linux ignores it).--chownno longer strips an existing setuid/setgid bit from the staged file: owner/group are now applied before mode, and a setuid/setgid/sticky bit already on disk is restored if the entry leaves mode at its default.-m/--mode(on bothinstallandscan) is now validated before anything is staged: it accepts 1-4 octal digits (normalized, so0644is stored as644),-,--orauto, and rejects everything else, including an explicit empty value, with exit 2. Previously a Python-literal spelling like0o644was recorded verbatim and made rpm silently package the file with mode 000, andscan -maccepted any string at all with no check.- An empty
mode,ownerorgroupis now treated like-(the documented default) everywhere it is read: applying the entry, and both the RPM and Debian dump formats. Previously the RPM format emitted%attr(,-,-), which rpmbuild rejects, for an empty mode. install -t/--typenow acceptsfile,directoryorsymlinkin any case and rejects anything else with exit 2 instead of a Python traceback;--type=--(or the Python API'sFileType._AUTO) now auto-detects from the source instead of raisingNotImplementedError.scan --typeis hidden from--help(scanalways records each path's own on-disk type) and now warns instead of silently doing nothing when given a value.--helpdescribes every option, names thePKGFORGE_*variables and the DB and dump formats, and no longer shows developer notes.- An unknown
--db-formatorPKGFORGE_DB_FORMATexits 2 with one line naming the valid formats, before anything is staged (was a traceback and exit 1, afterinstallhad staged the file), also when--dbis unset (was ignored). Constructing a command with an unknowndb_formatraisesUsageError(aValueError) immediately. - Usage, errors,
--versionand completion name the commandpkgforge(wasPkgForge), so completion binds. Regenerate installed ones. PKGFORGE_ROOT/PKGFORGE_DB/PKGFORGE_DB_FORMATare now read whenpkgforge.main()(orduho.parse) runs, instead of once when the package is imported. A build driver or test that sets one of these after importingpkgforge(e.g. viamonkeypatch.setenv, or a long-lived process that changes its own environment) is now honored; a--db/--buildroot/--db-formaton the command line still wins over the environment. A command constructed directly in Python, not throughmain(), is unaffected and keeps using the value from import time, as before.- An empty
PKGFORGE_DB_FORMAT(a common CI idiom for "unset") no longer makes every DB command fail withunknown db format ''; it is now treated as unset, same as an emptyPKGFORGE_DB/PKGFORGE_ROOTalready was. PkgForgeCmd.localpath()accepts astras well as aPath, and a relative input is now taken as already build-relative instead of silently dropping its first path component.import pkgforge,--helpand thejsonlbackend no longer require Python'ssqlite3module; choosing thesqlitebackend on an interpreter that lacks it fails with one clear line instead of anImportErrorfrom inside the stdlib. Theyamlbackend now imports PyYAML lazily too, with the same one-line error if it's ever missing.jsonl/yamlDBs are read and written as UTF-8 under any locale; before, a DB containing literal (non-ASCII-escaped) UTF-8 -- a hand edit or a third-party writer -- could be mis-decoded under a non-UTF-8 locale, andcompactwould then make that damage permanent.- An append to a
jsonl/yamlDB whose last line lacks a trailing newline (a hand edit, aprintf/echo -nwriter, a torn write) starts a new line instead of fusing the new record onto the old one and silently corrupting the DB. - The
yamlbackend reads scalars as written:mode: 0755is"0755"(previously the int493, dumped as%attr(493,...)and packaged as mode004). A missingmetaloads as{}, a missingmode/owner/groupas-, and ajsonlint mode755loads as"755". Aboolor float mode, a non-octal-digit int mode, a non-stringtype, or a record that is not a mapping now raisesDbErrorinstead of silently mispackaging or crashing later with a rawKeyError. - A flow-style YAML DB (
{/usr/bin/x: ...},{}) is still read as YAML. Appending to one now raisesDbErrorinstead of silently corrupting it;pkgforge --db FILE compactrewrites it in block style first. compact(jsonl/yaml) no longer truncates the DB before writing the compacted result: a failure partway through (a full disk, a kill) now leaves the original file completely unchanged instead of a torn or emptied one.jsonl/yamlwriters (install,scan,compactitself) now serialize through an advisory lock on the DB file, so runningcompactwhile another pkgforge process is appending to the same DB no longer silently drops the concurrent append.- Reading a
sqliteDB never writes to it any more: pointingdbdump(or any read) at an empty or foreign SQLite file no longer adds anentriestable to it. A SQLite file without anentriestable now raisesDbErrornaming the file, unless it has no tables at all, in which case it loads as an empty DB. A path, or a mode/owner/group/type field, that is not valid UTF-8 (e.g. an undecodable file name, or apwd/grpentry containing one) now raisesDbErrornaming it, instead of an interpreter-levelUnicodeEncodeErrortraceback partway through a scan. register_provider'ssuffixesnow add a missing leading dot ("toml"registers the same as".toml"), sincePath.suffixalways includes one and a dotless entry could never match a--dbpath before. A suffix with more than one dot (e.g.".tar.gz") is now rejected withValueErrorat registration time instead of silently never matching.dbdumpnow logs a WARNING when--dbis unset,-, or names a file that doesn't exist, and when the DB has entries but--excludeleaves none;initdbwarns when--dbis unset or-;compactwarns for either of those cases, and also for a--dbnaming a file that doesn't exist yet (previously it silently created an emptyjsonl/yamlfile in that case; now nothing is created, for every backend). Exit codes and output are unchanged -- a missing/unset/-DB has always read as empty; this just makes that visible instead of silent.
Security
rpmspecfilesrejects a path containing%: rpmbuild expanded it, so a staged%(...)name ran a shell command, and no quoting is literal on rpm both before and after 4.19; write that%filesline by hand.- Archives extracted with
bsdtar(stdin,.zip,.iso,.cpio) no longer restore owners, setuid/setgid, group/other write, xattrs, ACLs or file flags as root; an archive holding a device node, FIFO or socket is refused (exit 1). - Without tarfile's extraction filter (Python before 3.9.17, 3.10.12 or
3.11.4), a tar archive now routes to
bsdtar, or is refused (exit 1), instead of extracting with no path/symlink/special-file checks at all. - The quick start and unattended guide build in a private
mktemp -droot, not/tmp/stage; scripts copied from the old docs should do the same.
0.1.2 - 2026-08-16
Changed
- The
duhodependency is now>=0.5.0,<0.6. 0.1.1 capped it at<0.4to stay installable; the commands are now declared against duho's current argument model instead, so the cap is gone and pkgforge tracks the supported duho line again. install'ssourceis declared as alist[Path]rather than aUnion[list[Path], Path]. duho resolves a union by composing its members' scalar converters, so a collection member has no way to keep its own argparse action and the parser refused to build. The command still accepts a barePathfrom the Python API; only the annotation narrowed.--excludeoninstall,scananddbdumpis declared withduho.Append, making it a repeatable single-value option.
Fixed
--excludeworks through the command line again. It previously combined a greedynargswith an append action, soinstall --exclude=Pproduced a list of lists (AttributeError: 'list' object has no attribute 'pattern') and the bare-X P SRC DSTform swallowed the positionals. Both forms now parse to a flat statement list; the multi-source regression test drives the option through argv rather than assigning it directly.
Notes
-x/--decompressstill takes an optional argument and therefore still consumes the following token, soinstall -x SRC DSTreadsSRCas the compression kind. That is argparse's own behavior for this shape and the 0.1.1 guard rejecting a path-shaped kind remains in place and necessary.- The suite also passes against duho 0.4.x, but 0.5 is the series exercised and therefore the one declared. CI now runs one job pinned to the declared floor so it is verified rather than assumed.
0.1.1 - 2026-08-16
Added
py.typedmarker, so theTyping :: Typedclassifier is honored by type checkers (PEP 561).
Changed
- The
duhodependency is now>=0.3.2,<0.4. It was unbounded, so a fresh install of 0.1.0 resolved a duho the parser cannot build against and everypkgforgeinvocation failed. sniff_format()reads only the 16 bytes it inspects instead of the whole file.
Fixed
--excludewith an absolute pattern now works for every source of a multi-sourceinstall. The parsed statements were rewritten in place when bound to a source root, so the second source re-prefixed an already-rebased pattern (/a/**->/src2/src1/a/**) and silently excluded nothing.- An exclude statement that does not apply no longer vetoes the statements
after it. A directory that failed a recursive (
**) pattern returned a definite "keep" instead of falling through, making the outcome depend on statement order (-X '**/*.pyc' -X '(?type:directory)**/tmp'never reached the second statement). install -xwith a stdin source (-) raises a clearcannot infer compression from stdin; pass -x TYPEinstead of anAttributeError.install -xgiven a path as its kind is rejected with an explanation.-xtakes an optional argument, soinstall -x SRC DSTparsesSRCas the compression kind; with further sources the positionals silently shifted along by one and the source path was then run as a decompressor command.
Notes
- duho 0.4 and newer are not supported. From 0.4.0 the argument model rejects
Install.source'sUnion[List[Path], Path]and the parser fails to build, so the cap above is what makes this release installable; supporting 0.4+ requires re-declaringsourceandexcludeagainst that model. - On the supported duho range,
--excludepassed through the command line parses to a nested list andinstallthen fails; the exclude fixes above are reachable through the Python API.
0.1.0 - 2026-07-18
First release. pkgforge stages files into a build root, records their intended install metadata in a file DB, and renders that DB into RPM/Debian packaging manifests. Built on the duho declarative CLI framework; Python 3.9+, Linux runtime.
Added
- Commands:
install(stage a source and record its entry — files, directories, symlinks, decompression, hardlinks,--exclude,--chown,--remove-source),scan(walk a tree and record entries;--missingfills gaps),dbdump(render the DB to a packaging manifest),initdb, andcompact(collapse an append-log DB to one record per live path). - Dump formats:
rpmspecfiles(RPM%fileslines with%attr/%dirand ameta.rpmprefix) anddebian(install+permissionsartifacts). The format registry supports multi-artifact formats. - Pluggable file-DB backends behind one
DbProviderinterface:jsonl(append-only JSON Lines, the default),yaml(append-only YAML), andsqlite(a real SQLite store, upserted in place). Every command behaves identically across all three. The backend is inferred from the--dbsuffix (.jsonl/.ndjson,.yaml/.yml,.db/.sqlite/.sqlite3);--db-format/PKGFORGE_DB_FORMAToverrides it, and reading auto-detects a file's actual format.register_provider()lets a third-party package add its own backend;DbProvider,open_db, andregister_providerare exported from the package. - Environment-driven configuration for unattended builds (
PKGFORGE_ROOT,PKGFORGE_DB,PKGFORGE_DB_FORMAT),--version, and shell completion. - Tar-family archives extract via stdlib
tarfile(safedatafilter where supported);bsdtaris only a fallback for other formats (e.g..iso). - Documentation site (mkdocs-material) with a guide + API reference, a
benchmarks/runner, an end-to-end example, and CI (test.yml/release.yml).
Notes
- File-DB entries store
modeas an octal permission string; two sentinels (-= OS default,--= resolve from disk) defer a field to the staged file. - Hardlink install uses
os.linkfor portability across Python 3.9–3.13 (Path.link_towas removed in 3.12;Path.hardlink_toonly exists from 3.10).