Source code for meta_package_manager.managers.ghcup

# Copyright Kevin Deldycke <kevin@deldycke.com> and 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, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

from typing import ClassVar

from extra_platforms import LINUX_LIKE, MACOS, WINDOWS

from ..capabilities import search_capabilities, version_not_implemented
from ..manager import PackageManager

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

    from ..package import Package


_SELF_TOOL = "ghcup"
"""The one row of ghcup's own inventory that cannot be addressed as a package.

`ghcup list` reports the running ghcup among the installed tools, but its
argument parser rejects the name outright, with `'ghcup' is not a valid tool in
this context`, so the row round-trips through neither `install` nor `rm`. It is
dropped from the inventory rather than offered as a package mpm cannot act on.
"""

_UNAVAILABLE_NOTE = "no-bindist"
"""Note marking a version with no binary distribution for the running platform.

Such a row is a real release, listed because the metadata knows it, but nothing
`ghcup install` can fetch here. Kept out of search results, which exist to name
things the user can then install.
"""


[docs] class GHCup(PackageManager): """Haskell toolchain installer, covering GHC and the tools built around it. ghcup installs several kinds of tool side by side: GHC itself, plus `cabal`, `hls`, `stack` and whatever else its metadata offers. All of them are packages here, because `ghcup list` reports every kind in one flat listing whose every line names its own kind, and because `install`, `rm`, `set` and `whereis` all take the same `<tool> <version>` pair. Reporting only GHC versions would hide from the inventory tools mpm remains perfectly able to install and remove. A package is therefore identified as `<tool>-<version>`, and split back on its **first** hyphen to rebuild the pair. First rather than last is load-bearing: a cross-compiling GHC renders its target into the version cell, so `ghc-aarch64-unknown-linux-gnu-9.4.8` has to split into `ghc` and `aarch64-unknown-linux-gnu-9.4.8`, and that remainder is exactly the token ghcup's own version parser accepts. No tool name contains a hyphen today. ```{note} Every listing forces `--show-revisions none`. ghcup otherwise appends a `-rN` metadata-revision suffix to versions that have one, and that suffixed string is not a version `ghcup rm` will match: the inventory would then report packages that cannot be removed. The suffix appears only while a revision is pending, so the corruption is intermittent, which is worse than a consistent one. ``` ```{caution} Neither upgrade operation is declared, and neither is an oversight. `ghcup upgrade` upgrades **the ghcup binary itself**, not the tools it installs, so mapping it onto `upgrade --all` would replace the user's package manager when they asked to upgrade their packages. And ghcup has no in-place upgrade for a tool at all: a newer GHC is a fresh side-by-side install that leaves the old one in place, which is what `install` already does. ``` ```{note} No `outdated` either, for a reason that follows from the package identity above rather than from any missing command. Since the version is part of the id, a package having a newer version is a contradiction: the newer version is a *different* package, installed alongside rather than over. A report pairing `ghc-9.6.7` with a latest of `9.10.1` would also name an upgrade mpm cannot perform, both upgrade operations being absent. ghcup's own new-version notice is no help here: it is prose on stderr, it deduplicates itself against a cache file so a second run prints nothing, and it fires as a side effect of unrelated commands. ``` Documentation: [ghcup user guide](https://www.haskell.org/ghcup/guide/). """ name = "Haskell ghcup" homepage_url = "https://www.haskell.org/ghcup/" logo = "haskell" platforms = LINUX_LIKE, MACOS, WINDOWS requirement = ">=0.2.1.0" """First release accepting `--show-revisions`, which every listing needs to keep the `-rN` suffix out of versions that must round-trip to `ghcup rm`. Higher than the release the parsers themselves would need, and deliberately so: ghcup is installed by its own bootstrap script and upgrades itself rather than being carried by distributions, so a recent floor costs little. """ extra_env: ClassVar = { # The only color switch ghcup honors: it reads the variable's presence, # offers no --no-color flag, and performs no TTY detection at all, so a # piped run is colored too. "NO_COLOR": "1", # Silences the new-version notice ghcup prints to stderr after mutating # commands, which is chatter mpm never parses. "GHCUP_SKIP_UPDATE_CHECK": "1", } version_cli_options = ("--numeric-version",) """Preferred over `--version`, which wraps the number in a sentence and interpolates a build-time git description. This prints the bare version. """ version_regexes = (r"^(?P<version>\d+(?:\.\d+)+)",) r"""Search the bare version this option prints on its own. ```{code-block} shell-session $ ghcup --numeric-version 0.2.6.2 ``` """ @staticmethod def _parse_row(line: str) -> tuple[str, str, str] | None: """Split one `--raw-format` row into tool, version and notes. Raw rows are the four cells joined with single spaces and no padding, so an empty cell shows up as two spaces in a row. Splitting on a single space keeps those empties in place, where splitting on runs of whitespace would collapse them and shift every later cell left, landing a note in the tags column. """ cells = line.split(" ", 3) if len(cells) < 2: return None tool, version = cells[0], cells[1] if not tool or not version: return None notes = cells[3] if len(cells) > 3 else "" return tool, version, notes def _package_id(self, tool: str, version: str) -> str: """Join a tool and version into the id mpm keys the package on.""" return f"{tool}-{version}"
[docs] @staticmethod def split_package_id(package_id: str) -> tuple[str, str]: """Split a package id back into the `<tool> <version>` pair ghcup takes. Splits on the first hyphen, which is what keeps a cross-compiling GHC intact: its target triple lives in the version cell, so everything after the tool name belongs to the version. """ tool, _, version = package_id.partition("-") return tool, version
@property def installed(self) -> Iterator[Package]: """Fetch installed packages. `--show-criteria installed` filters upstream, so the installed marker never has to be read back off the glyph column, which `--raw-format` drops anyway. ```{code-block} shell-session $ ghcup list --raw-format --show-revisions none --show-criteria installed cabal 3.14.2.0 recommended ghc 9.6.7 recommended,base-4.18.3.0 ghcup 0.2.6.2 stray ``` """ output = self.run_cli( "list", "--raw-format", "--show-revisions", "none", "--show-criteria", "installed", ) for line in output.splitlines(): row = self._parse_row(line) if not row: continue tool, version, _notes = row if tool == _SELF_TOOL: continue yield self.package( id=self._package_id(tool, version), installed_version=version, )
[docs] @search_capabilities(extended_support=False, exact_support=False) def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. ghcup has no search command: the full listing *is* its catalog, and a small one, so mpm filters it itself. ```{caution} Search does not support extended or exact matching. ``` ```{code-block} shell-session $ ghcup list --raw-format --show-revisions none cabal 3.14.2.0 recommended ghc 9.6.7 recommended,base-4.18.3.0 ghc 9.8.1 2023-10-09 ``` """ output = self.run_cli("list", "--raw-format", "--show-revisions", "none") for line in output.splitlines(): row = self._parse_row(line) if not row: continue tool, version, notes = row # The self row is not installable, and a version with no binary # distribution for this platform cannot be fetched here. if tool == _SELF_TOOL or _UNAVAILABLE_NOTE in notes.split(","): continue yield self.package( id=self._package_id(tool, version), latest_version=version, )
[docs] @version_not_implemented def install(self, package_id: str, version: str | None = None) -> str: """Install one package. The version is already carried by the id, which is why this takes no version of its own. ```{code-block} shell-session $ ghcup install ghc 9.6.7 ``` """ tool, tool_version = self.split_package_id(package_id) return self.run_cli("install", tool, tool_version)
[docs] def remove(self, package_id: str) -> str: """Removes a package. Removing the version currently set neither fails nor warns: ghcup drops the symlinks and the set marker, leaving the tool simply unset. ```{code-block} shell-session $ ghcup rm ghc 9.6.7 ``` """ tool, tool_version = self.split_package_id(package_id) return self.run_cli("rm", tool, tool_version)
[docs] def sync(self) -> None: """Sync package metadata. ```{code-block} shell-session $ ghcup prefetch metadata ``` """ self.run_cli("prefetch", "metadata")
[docs] def cleanup_cache(self) -> None: """Removes things we don't need anymore. Confined to the download cache and the temporary directories. `ghcup gc` also offers switches that delete installed tools, which is not cleanup as mpm means it, and they are deliberately not passed. ```{code-block} shell-session $ ghcup gc --cache --tmpdirs ``` """ self.run_cli("gc", "--cache", "--tmpdirs")