Browser tools find agent-browser only in PM's store or on PATH and their readiness check never installs it, so a fresh install silently had no browser_* tools. Package.default marks an optional package that a bare `pm install` also carries; a failed download of it warns instead of failing the install. `pm install --without NAME` records the opt-out in declined-packages.json beside PM's install state, and naming the package explicitly clears it. agent-browser and chromium gain a Termux gap: Termux owns its browser stack and there is no bionic Chromium.
89 lines
3.6 KiB
Python
89 lines
3.6 KiB
Python
"""Optional packages the default install carries, and the user's opt-outs.
|
|
|
|
A ``default`` package (see ``Package.default``) joins the installers' PM stage,
|
|
a bare ``hermes pm install`` and ``hermes update`` on every target it builds
|
|
for. The user can decline one (``install.sh --skip-browser``,
|
|
``install.ps1 -SkipBrowser``, ``hermes pm install --without NAME``). The choice
|
|
is recorded per installation beside PM's other install state, so a later
|
|
update or bare install never re-adds it. An explicit
|
|
``hermes pm install NAME`` clears it.
|
|
|
|
Stdlib-only apart from PM's own modules: the installers run this before any
|
|
application dependency exists.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Iterable
|
|
from pathlib import Path
|
|
|
|
DECLINED_FILENAME = "declined-packages.json"
|
|
|
|
|
|
def declined_path(project_root: Path | None = None) -> Path:
|
|
from pm.environments import install_state_dir
|
|
from pm.paths import repo_root
|
|
|
|
return install_state_dir(repo_root() if project_root is None else Path(project_root)) / DECLINED_FILENAME
|
|
|
|
|
|
def declined(project_root: Path | None = None) -> frozenset[str]:
|
|
try:
|
|
data = json.loads(declined_path(project_root).read_text(encoding="utf-8-sig"))
|
|
except FileNotFoundError:
|
|
return frozenset()
|
|
except (OSError, ValueError) as exc:
|
|
# Silently treating a damaged record as "nothing declined" would
|
|
# re-add a package the user explicitly refused.
|
|
from pm.package import InstallError
|
|
|
|
raise InstallError("defaults", f"cannot read {declined_path(project_root)}: {exc}",
|
|
"fix or delete the file, then retry") from exc
|
|
names = data.get("declined") if isinstance(data, dict) else None
|
|
return frozenset(name for name in names or () if isinstance(name, str))
|
|
|
|
|
|
def record_declined(*, add: Iterable[str] = (), remove: Iterable[str] = (),
|
|
project_root: Path | None = None) -> frozenset[str]:
|
|
"""Update the recorded opt-outs; returns the new set. Writes only on change."""
|
|
from pm.filesystem import durable_write_bytes
|
|
|
|
current = declined(project_root)
|
|
updated = (current | set(add)) - set(remove)
|
|
if updated != current:
|
|
body = json.dumps({"schema": 1, "declined": sorted(updated)}, indent=2) + "\n"
|
|
durable_write_bytes(declined_path(project_root), body.encode("utf-8"))
|
|
return frozenset(updated)
|
|
|
|
|
|
def default_package_names() -> list[str]:
|
|
"""Every package that may be declined: the ``default`` ones, any target."""
|
|
from pm.registry import all_packages, get_package
|
|
|
|
return [name for name in all_packages() if get_package(name).default and not get_package(name).internal]
|
|
|
|
|
|
def default_packages(names: list[str], *, target: str | None = None,
|
|
declined_names: frozenset[str] | None = None) -> list[str]:
|
|
"""The optional defaults among ``names`` this install should carry.
|
|
|
|
Excludes targets the package has no build for (its ``gaps``) and packages
|
|
the user declined. ``names`` is the lockfile's package list.
|
|
"""
|
|
from pm.registry import get_package
|
|
from pm.store import current_target
|
|
|
|
target = current_target() if target is None else target
|
|
refused = declined() if declined_names is None else declined_names
|
|
selected = []
|
|
for name in names:
|
|
try:
|
|
package = get_package(name)
|
|
except KeyError:
|
|
continue # Lockfile/definition skew mid-update; drift() skips these too.
|
|
if package.default and not package.internal and name not in refused \
|
|
and package.missing_reason(target) is None:
|
|
selected.append(name)
|
|
return selected
|