From 11f1d2f425eb9fc47a4cf2ebcccfa54343623732 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 14 Aug 2026 08:39:45 +0800 Subject: [PATCH] feat(build): let a plugin own a package's install command The install command template is an index-wide string, so it can only say what is true of every package. That is the right default, but some ecosystems need the line to vary per package. dsh-index is the case that surfaced it. Its packages install through the ecosystem's own tool, and the spec differs per package: a bundle published to npm installs by name and version, while an unpublished neighbour -- 50 of its 68 packages -- installs from a pinned git commit. No single template covers both, and only the plugin knows which applies. _install_commands ran after every plugin hook and assigned unconditionally, so a plugin that set the command in on_package had it silently overwritten. That reads as the plugin API being ignored rather than as a precedence rule. It now skips a package whose command is already set. A package no plugin claims still gets the template, so opting one package in does not opt the whole index out. --- tests/test_plugins.py | 31 +++++++++++++++++++++++++++++++ xpkgindex/build.py | 17 +++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 3e69443..31a3622 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -88,6 +88,37 @@ def test_row_default_falls_back_to_install_command(repo): assert row.install == "tool add widget@1.0.0" +def test_plugin_may_own_the_install_command(repo): + """A per-package install command survives the index-wide template. + + The template runs after every plugin hook, so without an explicit + precedence rule it silently overwrote whatever the plugin set -- and a + plugin that can shape every other part of the page but not this line has + no way to show a spec that differs per package. + """ + plugin = write_plugin(repo, ''' + from xpkgindex.models import Identity + from xpkgindex.plugins import Plugin + + class P(Plugin): + name = "installs" + def identity(self, raw, path): + return Identity.joined(raw.get("namespace", ""), raw.get("name", "")) + def on_package(self, pkg, raw): + if raw.get("namespace") == "alpha": + pkg.extensions.setdefault("_core", {})["install_command"] = \\ + "native add widget#deadbeef" + ''') + write_config(repo, plugins=[plugin]) + + site, _ = build(repo, offline=True) + by_ref = {p.identity.install_ref: p for p in site.packages} + assert by_ref["alpha.widget"].install_command == "native add widget#deadbeef" + # The package the plugin did not claim still gets the template, so opting + # one package in does not opt the whole index out. + assert by_ref["beta.widget"].install_command == "tool add beta.widget@1.0.0" + + def test_blocks_and_facets_reach_the_model(repo): plugin = write_plugin(repo, ''' from xpkgindex.models import Block, Facet, FacetValue, Identity diff --git a/xpkgindex/build.py b/xpkgindex/build.py index 6d1a0e5..0602ae0 100644 --- a/xpkgindex/build.py +++ b/xpkgindex/build.py @@ -64,8 +64,25 @@ def _check_slugs(packages: List[Package]) -> None: def _install_commands(packages: List[Package], config: SiteConfig) -> None: + """Fill in each package's install command, unless its plugin set one. + + The template is an index-wide string, so it can only say the one thing + that is true of every package. That is the right default, but it cannot + express a command that varies per package -- and in an ecosystem whose + own tool installs a package directly, the per-package spec is exactly + what the reader needs first (`dsh plugin add @` for one package, + `... add github:owner/repo#` for its unpublished neighbour). Only + the plugin knows which. + + So a plugin may claim this line in `on_package`; a package it does not + claim still gets the template. Without the check this ran last and + silently overwrote the plugin, which reads as the plugin API being + ignored rather than as a documented precedence rule. + """ tmpl = config.install_command_template for pkg in packages: + if pkg.extensions.get("_core", {}).get("install_command"): + continue version = pkg.latest or "latest" try: cmd = tmpl.format(ref=pkg.identity.install_ref,