#!/usr/bin/python3
#
# packagemanager -- manage packages under the LFS/BLFS "pkgusr" model.
# Copyright (C) 2025 packagemanager contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, see .
"""
packagemanager -- one tool for managing packages under the LFS/BLFS "pkgusr"
model, rewritten to sit on top of the new offline `blfs` tool.
The pkgusr model
----------------
Every package is installed by its own dedicated *package-user* whose home is
/usr/src/. Because that user runs `make install`, every file the package
installs ends up owned by that user, so `list_package ` records the exact
manifest into /usr/src//pkg.lst. Collector groups (nimgnu_*) share
directories between package-users; u_* users run GUI apps for the human.
The commands
------------
`packagemanager --help` lists them, grouped by what you are doing, and that
list is generated beside the parsers themselves.
THERE WAS A SECOND LIST HERE. A hand-kept "Roadmap" marking check, search,
dependencies, install, remove and reload-pkg-list as [TODO] -- all six had
been implemented for releases, and none of the twenty commands added since
appeared at all. A list of commands maintained anywhere but next to the
commands goes stale silently, and this file is the place a reader starts.
Design decisions still open:
* should the CONFIGURE phase run as root? Every BLFS "Configuring" section
is written for root, and files it creates would be root-owned, which the
file tracking does not expect.
* the `.new` mechanism: three functions move stack files and it still
surprises people.
"""
import argparse
import hashlib
import glob
import json
import os
import re
import shlex
import shutil
import stat
import subprocess
import sys
import time
try:
import readline # enables arrow-key line editing in input() prompts
except ImportError:
readline = None
try: # used by `info` (and later commands) to look up users
import pwd
import grp
except ImportError: # non-unix: degrade gracefully
pwd = grp = None
# --------------------------------------------------------------------------- #
# configuration
# --------------------------------------------------------------------------- #
# /usr/src holds one directory (== home) per package-user. Overridable so the
# tool can be exercised against a fixture tree in testing.
# Where package users live. Config wins over the built-in default, so a
# system built with a different layout keeps it; the environment still wins
# over both, for tests and one-off runs.
def _base_dir_default():
"""The BASE the account roots hang under -- /usr/src, not /usr/src/pkgusr.
ONE KEY, TWO MEANINGS. `lfs` writes `pkgusr_home` as the FULL package-user
root (its LAYOUT_DEFAULTS say /usr/src/pkgusr); this read the same key as
the base and then appended PKGUSR_SUBDIR itself, so a tree configured by
lfs got both:
logs: /usr/src/pkgusr/pkgusr/p_wget/log
Taking the parent when the value already ends in the subdirectory makes the
two readings agree without either tool having to change what it writes --
and a base that does NOT end in it is still taken literally, which is what
a hand-written config means.
"""
# CONFIG_PATH is defined further down, so read the path the same way it
# does rather than depending on definition order.
path = os.environ.get("PKGUSR_CONFIG") or "/etc/pkgusr/packagemanager.conf"
sub = os.environ.get("PKGUSR_SUBDIR", "pkgusr")
try:
with open(path) as f:
for line in f:
k, _, v = line.partition("=")
if k.strip() == "pkgusr_home" and v.strip():
v = v.strip().rstrip("/")
if sub and os.path.basename(v) == sub:
return os.path.dirname(v) or "/"
return v
except OSError:
pass
return "/usr/src"
BASE_DIR = os.environ.get("PKGUSR_BASE") or _base_dir_default()
def appuser_home():
"""Where application users live -- separate from package users, because
they are accounts a person uses rather than build trees.
DEFAULT /home, NOT the package base. This returned BASE_DIR, so every
application user's home, shared directory and XDG profile link were
built under /usr/src -- `mkdir -p /usr/src/u_claude/Shared`, and a
~/Shared/Claude link pointing into the package tree. A build tree is
not a home. Configurable as before (`appuser_home` in the config), but
the default is now the one that is right.
"""
return load_config().get("appuser_home") or APPUSER_HOME_DEFAULT
# ONE SUBDIRECTORY FOR ALL OF THEM. The user's layout, recorded on the
# machine as appuser_home=/home/shared_user -- and ignored: the account was
# moved to a hard-coded "/home/", so /home filled up with u_* beside
# the real logins. The default is that subdirectory; the config key still
# overrides it.
APPUSER_HOME_DEFAULT = "/home/shared_user"
# /home/Shared (1.14.315). 1.14.312 put a hub of LINKS in
# /home/shared_user/Shared and argued against /home: a directory there that
# is not a home looks like one. Once the hub holds the DATA, that argument
# turns round: the shared directories are the one thing in /home/shared_user
# that is not private to an account, and taking them out is what lets every
# account home be 0700.
SHARED_HUB_DEFAULT = "/home/Shared"
def shared_hub():
"""Where every shared directory lives (1.14.312; the data itself since
1.14.315 -- see SHARED_HUB_DEFAULT).
It was ~/Shared of the main user. Two things were wrong with that:
* ONE PERSON'S HOME IS NOT A SYSTEM PATH. A program of your own, a
script, a launcher, an app account -- anything that wants "the
shared dirs" had to know whose home to look in. A fixed path is the
same answer for every account.
* The SHARED_* variables were enumerated from $HOME/Shared by EVERY
login -- and in an app account $HOME/Shared is its own data
directory, so u_claude got SHARED_ for each file in it.
Default /home/Shared: the real directories, one per account
(/home/Shared/Claude, 2770 u_claude:u_claude). Each account's ~/Shared
and the main user's ~/Shared are links into it, so nothing you type
changes. `shared_hub=` in packagemanager.conf moves it.
"""
return (load_config().get("shared_hub") or "").rstrip("/") \
or SHARED_HUB_DEFAULT
def _app_home(u):
"""An application user's home: what passwd says, or where they go.
passwd FIRST, because an account whose home has already been moved
(usermod -d) must not have its shared directory rebuilt somewhere else
-- which is exactly what happened: the homes were moved to /home and the
next `cfg app-users` run recreated Shared under /usr/src.
"""
return _home_of(u) or os.path.join(appuser_home(), u)
INSTALL_SCRIPTS_DIR = os.environ.get("PKGUSR_INSTALL_SCRIPTS", "/etc/pkgusr/install_scripts")
TMP_DIR = "/tmp/packagemanager"
# Configurable group-name prefixes, stored in a small config file. The
# collector prefix names shared install-dir groups (was hard-coded "nimgnu");
# the user prefix names application-user accounts (was hard-coded "u").
CONFIG_PATH = os.environ.get("PKGUSR_CONFIG", "/etc/pkgusr/packagemanager.conf")
# Version stamps. Bump PLAN_VERSION when the cached dry-run plan structure
# changes (so stale plans are ignored); TOOL_VERSION is the program version.
TOOL_VERSION = "1.14.371"
def _build_id():
"""A short fingerprint of this file's own contents.
A hand-maintained version number goes stale the moment someone forgets to
bump it -- and the whole reason for printing a version is to answer "am I
running the code that was just fixed?". This cannot go stale: it changes
whenever the file does.
"""
import hashlib
try:
with open(os.path.abspath(__file__), "rb") as fh:
return hashlib.md5(fh.read()).hexdigest()[:7]
except OSError:
return "unknown"
PLAN_VERSION = 1
_DEFAULT_CONFIG = {"collector_prefix": "sysgroup", "user_prefix": "u",
"pkgusr_prefix": "p", "cfguser_prefix": "cfg",
"main_user": ""}
_config = None
def load_config():
global _config
if _config is not None:
return _config
_config = dict(_DEFAULT_CONFIG)
# WHICH KEYS CAME FROM THE FILE, as opposed to the defaults above. The
# conflict warning below compared the lfs config against whatever was in
# _config, and a default counted -- so a system with no packagemanager.conf
# at all was told its settings disagreed:
# note: collector_prefix is 'sysgroup' here but 'nimgnu' in the lfs
# config -- using 'nimgnu'
# Nothing said sysgroup. That is the built-in default being reported as an
# opinion someone held.
from_file = set()
try:
with open(CONFIG_PATH) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
_config[k.strip()] = v.strip()
from_file.add(k.strip())
except OSError:
pass
# Settings that MUST match across the tools come from one place.
#
# collector_prefix decides which groups shared directories carry. The
# system was built with the prefix in the lfs config; if packagemanager
# used its own default instead, every package installed afterwards would
# join `sysgroup_*` groups while the built system uses `nimgnu_*` -- two
# parallel sets of groups over the same directories, and no error to say
# so. The lfs config is authoritative for these.
shared = _shared_lfs_config()
for key in _SHARED_WITH_LFS:
val = shared.get(key)
if not val:
continue
if key in from_file and _config.get(key) and _config[key] != val:
printWarning(
f"note: {key} is '{_config[key]}' here but '{val}' in the lfs "
f"config -- using '{val}', which is what the system was built "
f"with.")
_config[key] = val
return _config
# Keys owned by the lfs config, not by packagemanager's own file.
# EVERY setting both tools have. `lfs build-system session` asks for these
# once, on the host, and the answers are what the system was actually built
# with -- so they are authoritative here, not a default to fall back to.
#
# This was `collector_prefix` alone. The other three were collected by the
# same interview, written into the same config.json, copied into the same
# chroot, and then asked for again by first_run_setup as though nothing knew
# them.
# The layout keys travel with the tree too, so a build system with a
# different directory layout keeps it without the environment having to be
# right on every invocation (1.13.69 did this for the prefixes).
_SHARED_WITH_LFS = ("collector_prefix", "pkgusr_prefix", "cfguser_prefix",
"main_user", "pkgusr_subdir", "cfguser_subdir")
def _shared_lfs_config():
"""The lfs tool's settings, if it has any on this system."""
for cand in (os.environ.get("LFS_STORE"), "/usr/share/lfs",
os.path.expanduser("~/.local/share/lfs")):
if not cand:
continue
p = os.path.join(cand, "config.json")
try:
with open(p) as f:
return json.load(f)
except (OSError, ValueError):
continue
return {}
def _save_shared_key(key, value):
"""Write a key where the READER will look for it.
`config --pkgusr-prefix ""` wrote packagemanager.conf and reported
success -- while the value is read from /usr/share/lfs/config.json, which
is authoritative for the shared keys. `--show` then still said "p_, the
lfs config", and nothing had changed.
A setting that is saved somewhere the tool does not read is not saved.
"""
for cand in (os.environ.get("LFS_STORE"), "/usr/share/lfs",
os.path.expanduser("~/.local/share/lfs")):
if not cand:
continue
path = os.path.join(cand, "config.json")
if not os.path.isfile(path):
continue
try:
with open(path) as f:
data = json.load(f)
data[key] = value
with open(path, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
f.write("\n")
printInfo(" wrote %s to %s (it wins over packagemanager.conf)"
% (key, path))
return True
except (OSError, ValueError) as e:
printWarning(" could not write %s: %s" % (path, e))
return False
def save_config(cfg):
try:
os.makedirs(os.path.dirname(CONFIG_PATH) or ".", exist_ok=True)
with open(CONFIG_PATH, "w") as f:
f.write("# packagemanager configuration\n")
for k, v in cfg.items():
f.write(f"{k}={v}\n")
return True
except OSError:
return False
def config_exists():
return os.path.isfile(CONFIG_PATH)
def collector_prefix():
return load_config()["collector_prefix"].rstrip("_") + "_"
def user_prefix():
return load_config()["user_prefix"].rstrip("_") + "_"
def pkgusr_prefix():
"""Prefix on PACKAGE users: `p_` gives p_gcc, p_zlib.
Three kinds of account share one passwd file and one namespace, and
without prefixes they are indistinguishable from each other and from the
system's own accounts -- a package called `man` or `news` collides with a
real one. `p_` for packages, `u_` for application users, `_`
for the shared-directory groups.
An explicitly EMPTY value means "no prefix", which is how a tree built
before prefixes keeps working; it is not the same as unset.
"""
v = load_config().get("pkgusr_prefix", _DEFAULT_CONFIG["pkgusr_prefix"])
v = (v or "").rstrip("_")
return (v + "_") if v else ""
def cfguser_prefix():
"""Prefix on CONFIG-STEP users: `cfg_` gives cfg_bootscripts.
A config step used to be `p_cfg_bootscripts` -- the package prefix stacked
on top of the step name's own `cfg_`. Two prefixes on one account means
neither one identifies it: `p_` said "package" while the account lived in
the config root. One account, one prefix.
"""
v = load_config().get("cfguser_prefix", _DEFAULT_CONFIG["cfguser_prefix"])
v = (v or "").rstrip("_")
return (v + "_") if v else ""
def pkgusr_kind(name):
"""What KIND of account is this -- 'pkg', 'cfg' or 'app'?
The one place that decides. pkgusr_name and pkgusr_root both answer from
it, so a name cannot be given one kind's prefix and placed in another
kind's root -- which is exactly what produced `p_cfg_bootscripts` under
/usr/src/cfg.
Takes a bare name (`gcc`, `cfg_bootscripts`), an account name (`p_gcc`),
or a legacy stacked name (`p_cfg_bootscripts`).
"""
n = name or ""
ppfx = pkgusr_prefix()
if ppfx and n.startswith(ppfx):
n = n[len(ppfx):]
upfx = user_prefix()
if upfx and n.startswith(upfx):
return "app"
cpfx = cfguser_prefix()
if cpfx and n.startswith(cpfx):
return "cfg"
return "pkg"
def pkgusr_name(name):
"""Package name -> account name, with the prefix ITS KIND carries.
IDEMPOTENT, and that is the whole point: this is called on names typed by
a person (`gcc`), on directory names read back from disk (`p_gcc`), and on
values that already went through it. A second pass must not give p_p_gcc.
Because it is idempotent, every caller can apply it without first knowing
which kind of name it is holding.
A legacy stacked name is repaired on the way through: the package prefix is
stripped before the kind's own prefix is applied, so `p_cfg_bootscripts`
comes back as `cfg_bootscripts`.
"""
if not name:
return name
# Lower-case FIRST, exactly like lfs-helper's pkg_owner_name: the book's
# anchors are not consistent (DocBook, Wget, Which), and p_DocBook next
# to the helper's p_docbook is two accounts for one package -- a failure
# box this session printed both names in one breath.
name = name.lower()
kind = pkgusr_kind(name)
# an application user or a collector group is not a package and is never
# renamed here
if kind == "app" or name.startswith(collector_prefix()):
return name
pfx = cfguser_prefix() if kind == "cfg" else pkgusr_prefix()
n = name
ppfx = pkgusr_prefix()
if ppfx and n.startswith(ppfx):
n = n[len(ppfx):]
if not pfx or n.startswith(pfx):
return n
return pfx + n
def unprefix_pkgusr(name):
"""Package-user name -> package name, for display."""
pfx = pkgusr_prefix()
if pfx and name.startswith(pfx):
return name[len(pfx):]
return name
# Accounts live in subdirectories of BASE_DIR by kind, so `ls /usr/src` says
# what a thing IS rather than listing a hundred packages and a few config
# steps as one flat set:
#
# /usr/src/pkgusr/p_gcc a package
# /usr/src/cfg/cfg_bootscripts a config step that installs files
# /usr/src/u_firefox an application user
def _early_conf(key, default):
"""A value from packagemanager.conf, read before load_config() exists.
THE DIRECTORIES HAD NO CONFIG KEYS. BASE_DIR has read `pkgusr_home` from
this file for a long time; the two subdirectories were ENVIRONMENT ONLY,
so a tree with a different layout depended on the environment being right
every time -- and could not be made tree-specific the way the prefixes
now are (1.13.69).
Precedence, as everywhere else: environment, then the file, then the
built-in default.
"""
env = {"pkgusr_subdir": "PKGUSR_SUBDIR",
"cfguser_subdir": "CFGUSR_SUBDIR"}.get(key)
if env and os.environ.get(env) is not None:
return os.environ[env]
# The LFS config is authoritative for the layout as it is for the
# prefixes -- that is what makes it travel with the tree rather than
# depending on the environment of whoever runs the command.
for cand in (os.environ.get("LFS_STORE"), "/usr/share/lfs",
os.path.expanduser("~/.local/share/lfs")):
if not cand:
continue
try:
import json as _json
with open(os.path.join(cand, "config.json")) as f:
v = _json.load(f).get(key)
if v is not None:
return str(v)
except (OSError, ValueError):
continue
path = os.environ.get("PKGUSR_CONFIG") or "/etc/pkgusr/packagemanager.conf"
try:
with open(path) as f:
for line in f:
k, _, v = line.partition("=")
if k.strip() == key:
return v.strip() # an empty value is an answer
except OSError:
pass
return default
PKGUSR_SUBDIR = _early_conf("pkgusr_subdir", "pkgusr")
CFGUSR_SUBDIR = _early_conf("cfguser_subdir", "cfg")
def pkgusr_root(name):
"""Which root an account belongs under. Takes any kind of name.
Answers from pkgusr_kind, the same function pkgusr_name uses, so the
prefix an account is given and the root it is placed in cannot disagree.
"""
kind = pkgusr_kind(name)
if kind == "app":
return BASE_DIR
sub = CFGUSR_SUBDIR if kind == "cfg" else PKGUSR_SUBDIR
return os.path.join(BASE_DIR, sub) if sub else BASE_DIR
def pkgusr_roots():
"""Every account root, for the passes that walk all of them."""
seen, out = set(), []
for sub in (PKGUSR_SUBDIR, CFGUSR_SUBDIR):
d = os.path.join(BASE_DIR, sub) if sub else BASE_DIR
if d not in seen:
seen.add(d)
out.append(d)
return out
def pkgusr_home(name):
"""Where a package user lives. Takes either kind of name."""
return os.path.join(pkgusr_root(name), pkgusr_name(name))
def main_user():
return load_config().get("main_user", "").strip()
def editor():
return (load_config().get("editor") or os.environ.get("EDITOR") or "vim")
def difftool():
return (load_config().get("difftool") or "vimdiff")
# --------------------------------------------------------------------------- #
# colored output
# --------------------------------------------------------------------------- #
_USE_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
class colors:
normal = "\033[0m" if _USE_COLOR else ""
red = "\033[31m" if _USE_COLOR else ""
green = "\033[32m" if _USE_COLOR else ""
yellow = "\033[33m" if _USE_COLOR else ""
blue = "\033[34m" if _USE_COLOR else ""
cyan = "\033[36m" if _USE_COLOR else ""
dim = "\033[2m" if _USE_COLOR else ""
HEADER_WIDTH = 60
def printHeader(msg):
inner = f"### {msg} "
tail = "#" * max(HEADER_WIDTH - len(inner), 3)
print(f"\n{colors.blue}{inner}{tail}{colors.normal}")
def printLine():
line = f"{colors.blue}{'-' * HEADER_WIDTH}{colors.normal}"
print(line)
return line
def printSuccess(msg): print(f"{colors.green}{msg}{colors.normal}")
def printInfo(msg): print(f"{colors.blue}{msg}{colors.normal}")
# Warnings go to STDERR, like lfs-helper's warn(). A note printed to stdout
# lands inside whatever a caller is capturing -- the config-conflict note was
# read back as part of `print(pm.BASE_DIR)` by anything scripting this module,
# and the value it polluted was correct.
def printWarning(msg): print(f"{colors.yellow}{msg}{colors.normal}", file=sys.stderr)
def printError(msg): print(f"{colors.red}{msg}{colors.normal}")
def ask_yes_no(question):
while True:
resp = input(f"{question} [y|n]: ").strip().lower()
if resp == "y":
return True
if resp == "n":
return False
print("Please enter 'y' for yes or 'n' for no.")
# --------------------------------------------------------------------------- #
# shared helpers
# --------------------------------------------------------------------------- #
# BASE_DIR is /usr/src, which holds more than package users -- lfs-pkgusr,
# a stray root/, whatever else lives there. Listing every directory reported
# them as packages ("root broken has a home dir but no user"). An account
# either exists in the user database or carries the skeleton every package
# user is created with.
_ACCOUNT_MARKERS = ("build.conf", ".project", "install_last", "pkg.lst",
".bash_profile")
def _looks_like_an_account(path, name):
# the prefix IS the evidence: p_gcc is a package user by construction
for pfx in (pkgusr_prefix(), user_prefix(), cfguser_prefix()):
if pfx and name.startswith(pfx):
return True
# NOT "a user of this name exists" -- /usr/src/root passed that test
# because root exists, and came out of verify as a broken package. A
# system user is not evidence that a DIRECTORY is a package home.
return any(os.path.lexists(os.path.join(path, m)) for m in _ACCOUNT_MARKERS)
def list_all_users():
"""Every package-user, i.e. every directory directly under BASE_DIR."""
# Directory names ARE user names, so they carry the prefix. Hand back
# package names: everything user-facing speaks in package names, and
# pkgusr_name() puts the prefix back wherever it is needed.
#
# A root that does not exist is normal -- a system with no config-step
# packages has no cfg/ directory. ALL of them missing is not, and must
# still be reported: swallowing it turns "nothing is installed here" into
# a silent empty list, which reads as "no packages".
out, found = [], False
for root in pkgusr_roots():
try:
names = os.listdir(root)
except OSError:
continue
found = True
# A COLLECTOR GROUP IS NOT A PACKAGE.
#
# `nimgnu_perl-modules`, `nimgnu_python-modules` and friends are
# GROUPS that own shared install directories. They were listed as
# package-users and reported "broken -- has a user but no home dir",
# so a verify of a healthy system ended with four scary lines about
# things that are working exactly as designed. A false alarm in a
# health check is worse than no check: it is what teaches people to
# skip the output.
# BOTH SPELLINGS. lfs-helper fell back to "nimgnu" while this tool
# and `lfs` fell back to "sysgroup" (fixed in 1.14.35), so a system
# whose config never set the key has groups under the OTHER name.
# Recognise them however they were created; the config still decides
# what NEW ones are called.
_cps = {collector_prefix(), "sysgroup_", "nimgnu_"}
# ONE UNDERSCORE. collector_prefix() already ends in "_", so this
# tested startswith("sysgroup__") and never matched anything -- the
# filter above has never worked, and every collector group was still
# listed as a "broken" package.
# ...and check the name BOTH ways. A directory may be `p_nimgnu_x`
# (the package prefix stacked on a group name) or plain `nimgnu_x`;
# the raw name misses the first, the unprefixed one misses nothing.
for d in names:
if not os.path.isdir(os.path.join(root, d)):
continue
bare = unprefix_pkgusr(d)
if any(c and (d.startswith(c) or bare.startswith(c)) for c in _cps):
continue
if not _looks_like_an_account(os.path.join(root, d), d):
continue
out.append(bare)
if not found:
printError(f"{BASE_DIR} does not exist (looked in "
f"{', '.join(pkgusr_roots())}).")
return []
return sorted(set(out))
# --------------------------------------------------------------------------- #
# bridge to the `blfs` tool (offline book parser / script generator)
#
# packagemanager no longer parses the book itself -- it shells out to `blfs`,
# which is fast (cached) and knows the real 13.0 structure. Location/book can
# be steered with env vars so the same code works in production and in tests:
# BLFS_BIN e.g. "blfs" or "python3 /path/blfs" (default: auto-detect)
# BLFS_BOOK_FILE passed through as `--book-file ` (offline/testing)
# BLFS_BOOK passed through as `--book `
# --------------------------------------------------------------------------- #
def find_blfs():
override = os.environ.get("BLFS_BIN")
if override:
return override.split()
here = os.path.dirname(os.path.abspath(__file__))
local = os.path.join(here, "blfs")
if os.path.isfile(local):
# os.access(X_OK) answers "yes" for root even without any execute bit,
# so check the mode bits themselves -- exactly the situation in a
# fresh copy of the tools, and the exec then dies with EACCES.
ex = bool(os.stat(local).st_mode & 0o111)
return [local] if ex else [sys.executable or "python3", local]
return ["blfs"] # rely on $PATH
# A PACKAGE MAY NEED A DIFFERENT BOOK, AND ONLY A WHOLE PAGE IS COHERENT.
#
# The systemd book's linux-pam page lists systemd as required; the 12.4 SysV
# book's linux-pam page has no Required section at all -- it wants only
# Shadow. Same package, two books, two dependency graphs, and this machine
# is SysV.
#
# NOT a merge. Taking the dependency list from one book and the commands
# from another produces a script neither book has ever tested: the versions
# differ (1.7.1 vs 1.7.2), the configure switches differ, the doc tarball
# differs. A whole page from the older book is something upstream shipped
# and someone ran. Half of each is not.
#
# machine.conf:
# [linux-pam]
# book = /usr/share/blfs/books/BLFS-BOOK-12_4-nochunks.html
#
# ...and everything that asks the book about THAT package -- versions,
# dependencies, the generated script -- asks the one named there.
_BOOK_FOR_PKG = {}
# A STACK CAN READ ITS PACKAGES FROM ANOTHER BOOK (1.14.321).
#
# media.stack: from-book svn
#
# ...and every book entry of THAT file (not of files it includes) is read
# from the development book: versions, dependencies, the generated script.
# It is a property of the package, not of one run -- `update`, `info` and
# `install --reinstall` ask the same book the stack built it from, or an
# update-all would "update" mpv back to the stable book's older page.
#
# Dependencies outside the stack stay on their own book: an installed one is
# left alone (the svn page naming a newer glib is not a reason to rebuild
# glib), a missing one is built from the default book, and only a page the
# default book does not have at all is read from the stack's book.
_STACK_BOOKS = None # anchor (lower) -> selector
_PLAN_BOOK = None # the stack book a running plan's target reads
_BOOK_PATHS = {} # selector -> local file ("" = could not resolve)
def _stack_book_map():
"""anchor -> selector, from every installed stack's `from-book`."""
global _STACK_BOOKS
if _STACK_BOOKS is not None:
return _STACK_BOOKS
_STACK_BOOKS = {}
# a stack run hands its own file's books to the installs it starts
# (it may be a checkout, not the installed copy)
for kv in (os.environ.get("PM_STACK_BOOKS") or "").split(","):
if "=" in kv:
a, sel = kv.split("=", 1)
_STACK_BOOKS[a.strip().lower()] = sel.strip()
d = STACK_DIR if "STACK_DIR" in globals() else "/etc/pkgusr/stacks"
for f in sorted(glob.glob(os.path.join(d, "*.stack"))):
try:
for r in _parse_stack_file(f):
sel = r["opts"].get("from-book")
if sel and r["kind"] == "book":
_STACK_BOOKS.setdefault(r["target"].lower(), sel)
except (ValueError, OSError):
continue
return _STACK_BOOKS
def _book_path_for(selector, refresh=False):
"""The local file for a book selector, asked of `blfs path` ONCE.
With refresh, `blfs fetch` first -- a stack run on the svn book wants
today's svn book; every other command reads the cached copy and never
touches the network."""
if not selector:
return None
if os.path.isfile(selector):
return selector
if refresh or selector not in _BOOK_PATHS:
if refresh:
set_status("refreshing the %s book ..." % selector)
try:
r = subprocess.run(find_blfs() + ["fetch", selector],
capture_output=True, text=True)
finally:
clear_status()
if r.returncode != 0:
printWarning(" could not refresh the %s book; using the "
"cached one" % selector)
r = subprocess.run(find_blfs() + ["path", selector],
capture_output=True, text=True)
out = (r.stdout or "").strip().splitlines()
_BOOK_PATHS[selector] = out[-1] if r.returncode == 0 and out \
and os.path.isfile(out[-1]) else ""
if not _BOOK_PATHS[selector]:
printWarning(" no %s book on disk (blfs fetch %s)"
% (selector, selector))
return _BOOK_PATHS[selector] or None
def _book_override(anchor):
"""The book this package should be read from, or None.
machine.conf `book =` first, then a stack's `from-book`, then -- inside
a plan for a stack-book package -- that book for a page the default
book does not have at all."""
if not anchor:
return None
if anchor not in _BOOK_FOR_PKG:
opts = _machine_opts_for(anchor)
sel = opts.get("book") or _stack_book_map().get(anchor.lower()) or ""
_BOOK_FOR_PKG[anchor] = (_book_path_for(sel) or sel) if sel else ""
if _BOOK_FOR_PKG[anchor]:
return _BOOK_FOR_PKG[anchor]
if _PLAN_BOOK and not _map_lookup(_versions_for(""), [anchor]) \
and _map_lookup(_versions_for(_PLAN_BOOK), [anchor]):
return _PLAN_BOOK
return None
def _blfs_book_flags(anchor=None):
over = _book_override(anchor) if anchor else None
if over:
if os.path.isfile(over):
return ["--book-file", over]
return ["--book", over]
if os.environ.get("BLFS_BOOK_FILE"):
return ["--book-file", os.environ["BLFS_BOOK_FILE"]]
if os.environ.get("BLFS_BOOK"):
return ["--book", os.environ["BLFS_BOOK"]]
return []
# what each blfs subcommand is actually doing, for the status line
_BLFS_ACTIVITY = {
"order": "working out the build order for %s",
"debug": "looking up %s in the book",
"deps": "reading %s's dependencies",
"script": "writing the install script for %s",
"versions": "reading versions from the book",
"search": "searching the book for %s",
}
# distinct blfs notes already shown this invocation (see run_blfs)
_FORWARDED_NOTES = set()
_SAID_BOOK = set()
def run_blfs(args, book=None):
"""Run blfs; return (returncode, stdout, stderr). rc 127 => blfs missing.
book= reads that book file for this call, whatever the package.
Each call is a subprocess, and the first one for a given book INDEXES it,
which takes many seconds. Announce what is happening: an install that sits
silent for half a minute before its first output looks frozen."""
# the package a call is ABOUT is its first non-flag argument, which is
# what decides whether an override applies
_anchor = next((a for a in args[1:] if not a.startswith("-")), None) \
if len(args) > 1 else None
if book:
cmd = find_blfs() + ["--book-file", book] + list(args)
else:
cmd = find_blfs() + _blfs_book_flags(_anchor) + list(args)
# ONCE per package: a plan asks blfs several things about each one
if not book and _anchor and _book_override(_anchor) \
and _anchor not in _SAID_BOOK:
_SAID_BOOK.add(_anchor)
printInfo(" %s: read from %s"
% (_anchor, os.path.basename(_book_override(_anchor))))
verb = args[0] if args else ""
if verb in _BLFS_ACTIVITY:
arg = args[1] if len(args) > 1 and not args[1].startswith("-") else ""
try:
set_status(_BLFS_ACTIVITY[verb] % arg if "%s" in _BLFS_ACTIVITY[verb]
else _BLFS_ACTIVITY[verb])
except Exception:
pass
try:
r = subprocess.run(cmd, capture_output=True, text=True)
# blfs says important things on stderr -- "note: no book matched
# '13.0-systemd'; using the cached ..." is a decision being
# overridden, and capturing it silently is how a wrong book got used
# without a word. Forward each DISTINCT note once: a plan touching
# ninety packages makes ninety blfs calls, and the same note ninety
# times is noise wearing a warning's colour.
_notes = [ln for ln in (r.stderr or "").splitlines()
if ln.startswith("note:") and ln not in _FORWARDED_NOTES]
if _notes:
try:
clear_status()
except Exception:
pass
for ln in _notes:
_FORWARDED_NOTES.add(ln)
printWarning(ln)
return r.returncode, r.stdout, r.stderr
except FileNotFoundError:
return 127, "", "blfs executable not found"
finally:
# the status line this call set is ITS to clear: left standing, the
# caller's next print lands on the same line
# ("reading versions from the bookChecking 97 package(s)...")
if verb in _BLFS_ACTIVITY:
try:
clear_status()
except Exception:
pass
_VERSION_MAPS = {} # book file ("" = default) -> {anchor: nv}
_BOOK_MISSES = set() # names no book had: ask the slow way once
def _versions_for(book):
"""The whole version map of one book, loaded once per process."""
if book not in _VERSION_MAPS:
try:
_VERSION_MAPS[book] = book_versions_all(book or None) or {}
except Exception:
_VERSION_MAPS[book] = {}
return _VERSION_MAPS[book]
def _map_lookup(vmap, names):
for a in names:
if a in vmap:
return vmap[a]
for a in names:
low = a.lower()
for k, nv in vmap.items():
if k.lower() == low:
return nv
return None
def book_version(anchor):
"""Current name-version of a package in ITS book, or None.
One version map per book, loaded once (see book_versions_all); a name
no map has falls back to `blfs debug` once, and a miss is remembered --
`install imv` asked three slow questions to learn the same "no" three
times. The package's book is machine.conf's, else its stack's
from-book, else the default.
THE PREFIX IS NOT PART OF THE BOOK'S NAME: `info webkitgtk` asks with
the account name p_webkitgtk, which no anchor matches.
"""
_tries = [anchor]
for _pre in (pkgusr_prefix(), user_prefix(), collector_prefix()):
if _pre and anchor.startswith(_pre):
_tries.append(anchor[len(_pre):])
own = _book_override(_tries[-1])
books = ([own] if own and os.path.isfile(own) else []) + [""]
if _PLAN_BOOK and _PLAN_BOOK not in books:
books.append(_PLAN_BOOK)
for b in books:
hit = _map_lookup(_versions_for(b), _tries)
if hit:
return hit
if _tries[-1] in _BOOK_MISSES:
return None
rc, out, _ = run_blfs(["debug", _tries[-1]])
m = re.search(r"^title\s*:\s*(.+)$", out, re.M) if rc == 0 else None
nv = m.group(1).strip() if m else None
if nv:
_versions_for(books[0])[anchor] = nv # ask once, even for an alias
else:
_BOOK_MISSES.add(_tries[-1])
return nv
def default_book_version(anchor):
"""The version the DEFAULT book has, whatever book the package reads."""
return _map_lookup(_versions_for(""), [anchor])
def book_rdeps(target, no_recommended=False, optional=False):
"""anchors of packages that recursively depend on `target`, via blfs rdeps."""
argv = ["rdeps", target, "--anchors"]
if no_recommended:
argv.append("--no-recommended")
if optional:
argv.append("--optional")
rc, out, _ = run_blfs(argv)
result = {}
if rc == 0:
for line in out.splitlines():
if "\t" in line:
a, nv = line.split("\t", 1)
if a.strip():
result[a.strip()] = nv.strip()
return result
def _versions_cache_path(book=""):
"""Where the anchor -> version map is kept between runs: one per book."""
d = os.path.join("/var", "cache", "pkgusr")
try:
os.makedirs(d, exist_ok=True)
except OSError:
d = "/tmp"
tag = hashlib.md5(book.encode()).hexdigest()[:8] if book else "default"
return os.path.join(d, "book-versions-%s.json" % tag)
def _default_book_file():
"""The file the default book is: BLFS_BOOK_FILE, BLFS_BOOK, `blfs path`.
NOT the newest file on disk: once a stack fetched the svn book it was
the newest, and every version question on the machine would have been
answered from svn."""
_bf = os.environ.get("BLFS_BOOK_FILE") or ""
if _bf and os.path.isfile(_bf):
return _bf
if not hasattr(_default_book_file, "_p"):
_p = ""
try:
r = subprocess.run(find_blfs() + (
["--book", os.environ["BLFS_BOOK"]]
if os.environ.get("BLFS_BOOK") else []) + ["path", "--offline"],
capture_output=True, text=True)
out = (r.stdout or "").strip().splitlines()
if r.returncode == 0 and out and os.path.isfile(out[-1]):
_p = out[-1]
except OSError:
pass
if not _p: # an older blfs without `path`
_books = sorted((f for f in glob.glob("/usr/share/blfs/books/*.html")
if "svn" not in os.path.basename(f)),
key=lambda f: os.stat(f).st_mtime, reverse=True)
_p = _books[0] if _books else ""
_default_book_file._p = _p
return _default_book_file._p
def book_versions_all(book=None):
"""Map anchor -> name_version for the whole book (default, or `book`).
ONE CALL WAS NOT ENOUGH: IT IS ONE CALL PER PROCESS. `blfs versions`
re-reads the whole book to build this map, which is eighteen seconds on
a 12 MB book -- and `info` pays it to answer one question. The page
index is cached; this map was not, so every command that wanted a
version rebuilt it from scratch.
Cached on disk against the book's own size and mtime: the moment the
book file changes the cache is ignored, so a stale map cannot outlive
the book it came from.
"""
# WHICH book: the one asked for, else the default book's file.
_bf = book if book and os.path.isfile(book) else _default_book_file()
_key = None
if _bf:
try:
_st = os.stat(_bf)
_key = "%s:%d:%d" % (_bf, _st.st_size, int(_st.st_mtime))
except OSError:
_key = None
_cp = _versions_cache_path(_bf)
if _key:
try:
with open(_cp, encoding="utf-8") as f:
_c = json.load(f)
if _c.get("key") == _key and isinstance(_c.get("versions"), dict):
return _c["versions"]
except (OSError, ValueError):
pass
rc, out, _ = run_blfs(["versions"], book=_bf or None)
versions = {}
if rc == 0:
for line in out.splitlines():
if "\t" in line:
a, nv = line.split("\t", 1)
a, nv = a.strip(), nv.strip()
if a and nv and " " not in a:
versions[a] = nv
if versions and _key:
# CONFIRM IT CAN BE READ BACK, not just written: a cache that fails
# to load silently turns into the 18 seconds all over again, on
# every command, invisibly.
try:
with open(_cp, "w", encoding="utf-8") as f:
json.dump({"key": _key, "versions": versions}, f)
with open(_cp, encoding="utf-8") as f:
if json.load(f).get("key") != _key:
printWarning(" the version cache did not read back; "
"every command will rebuild it")
except (OSError, ValueError) as e:
printInfo(" (not caching the book version map: %s)" % e)
return versions
def cmd_blfs(args):
"""Pass through to the blfs book tool: books, set-default, search, etc.
e.g. packagemanager blfs books --discover / packagemanager blfs set-default svn"""
passthrough = args.args
if passthrough and passthrough[0] == "--":
passthrough = passthrough[1:]
cmd = find_blfs() + _blfs_book_flags() + passthrough
try:
sys.exit(subprocess.run(cmd).returncode)
except FileNotFoundError:
printError("blfs not found (set BLFS_BIN or put blfs on PATH).")
sys.exit(127)
def find_pm_install():
"""The install front door. packagemanager_install is retired (item 6,
step 7); the front-matter and the loop both live in lfs-helper now, behind
`lfs-helper pm-install