# Copyright Kevin Deldycke <[email protected]> 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
import re
from typing import ClassVar
from extra_platforms import ALL_PLATFORMS
from ..capabilities import search_capabilities
from ..manager import PackageManager
from ..version import parse_version
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from ..package import Package
[docs]
class LuaRocks(PackageManager):
"""LuaRocks, the package manager for Lua modules.
A package is a *rock*, identified by the bare name every listing prints. Its
version carries a packaging revision after a dash (`3.1.2-0`), kept verbatim
because that is the form `luarocks install` accepts back.
```{caution}
The listing reports every configured tree at once, while `install` and
`remove` act on the default tree only: removing a rock that lives in
another tree fails and names the tree it searched.
```
"""
# Every read passes `--porcelain`, which replaces the default grouped
# display with one tab-separated record per line.
#
# Search is what makes this a class rather than a bundled definition:
# `luarocks search` prints one row per *(rock, version, kind)* triple, so
# a single rock comes back a dozen times over: once per published
# version, and again for each of the `rockspec` and `src` forms it ships
# in. mpm keys a package on its id alone, so the rows are reduced here to
# one entry per rock. The installed and outdated listings get the same
# treatment, LuaRocks being able to hold several versions of a rock in
# one tree.
#
# `--no-project` is required. LuaRocks walks up from the working
# directory looking for a project tree and silently switches to it when
# one is found, so without the flag the inventory would answer for
# whichever directory mpm happened to be invoked from instead of for the
# machine. `haxelib` forces `--global` against the same hazard. The flag
# is a global one, placed before the subcommand, which is why it is
# declared as `pre_args` rather than repeated per operation. The version
# probe skips `pre_args` entirely and so runs bare.
#
# Reads and writes disagree about scope, and deliberately so: `list`
# reporting every configured tree at once is the right answer for an
# inventory of the machine, while `install` and `remove` acting on the
# default tree only is left as LuaRocks defines it. Forcing `--local`
# would make the user tree writable at the cost of the system one, and
# forcing `--global` the reverse; neither is right for every host, and
# the failure is loud, immediate and names the tree it searched.
#
# `upgrade --all`: LuaRocks has no command updating every installed rock,
# `install` being what upgrades a named one in place. mpm backfills the
# bulk case from `outdated` plus the per-rock upgrade (rendered by the
# synthesized note of the operations table).
#
# `sync`: nothing refreshes the manifest without also downloading.
#
# `cleanup`: `luarocks purge` empties an entire tree rather than
# reclaiming anything, which is a mass removal and not a cleanup.
operation_notes: ClassVar = {
"installed": (
"A rock can be held at several versions in one tree; the listing "
"reports one entry per rock."
),
"search": (
"Results are reduced to one entry per rock, the tool printing one "
"row per version and per form."
),
"sync": "Nothing refreshes the manifest without also downloading.",
"cleanup": (
"The `purge` command empties an entire tree rather than "
"reclaiming anything."
),
}
name = "LuaRocks"
homepage_url = "https://luarocks.org"
documentation_url = "https://github.com/luarocks/luarocks/wiki/Documentation"
repository_url = "https://github.com/luarocks/luarocks"
wikipedia_url = "https://en.wikipedia.org/wiki/LuaRocks"
platforms = ALL_PLATFORMS
requirement = ">=3.9.1"
"""The release adding `--no-project`, which is what pins every read to the
machine instead of to the working directory's project tree.
"""
pre_args = ("--no-project",)
"""Ignore any project tree found by walking up from the working directory,
so the inventory describes the machine and not the current directory.
"""
version_regexes = (r"luarocks(?:\.exe)?[ \t]+(?P<version>\d+\.\d+(?:\.\d+)?)",)
r"""Search the version right after the binary path LuaRocks echoes back.
```{code-block} shell-session
$ luarocks --version
/opt/homebrew/bin/luarocks 3.13.0
LuaRocks main command-line interface
```
The path is printed in full and varies per host, so the pattern anchors on
the binary name rather than on the start of the line.
"""
_INSTALLED_REGEXP = re.compile(
r"^(?P<package_id>[^\t]+)\t(?P<installed_version>[^\t]+)\tinstalled\t",
)
"""One installed rock: name, version, the literal `installed` state, then the
tree holding it. The state column is matched so a row of any other kind is
skipped rather than misread.
"""
_OUTDATED_REGEXP = re.compile(
r"^(?P<package_id>[^\t]+)\t(?P<installed_version>[^\t]+)\t"
r"(?P<latest_version>[^\t]+)\t",
)
"""One upgradable rock: name, the version held, the version available, then
the server offering it.
"""
_SEARCH_REGEXP = re.compile(
r"^(?P<package_id>[^\t]+)\t(?P<latest_version>[^\t]+)\t",
)
"""One search hit: name and version. The kind and server columns are dropped,
being what makes the same rock repeat across rows.
"""
def _newest_per_rock(
self,
rows: Iterable[tuple[str, str | None, str | None]],
) -> Iterator[Package]:
"""Reduce rows to one package per rock, keeping the highest version.
LuaRocks repeats a rock across rows, by installed version in a listing
and by published version and packaging kind in a search. Ranking is done
on whichever version the row carries: the installed one where there is
one, the available one otherwise.
"""
best: dict[str, tuple[str | None, str | None]] = {}
for package_id, installed, latest in rows:
ranked = installed or latest
current = best.get(package_id)
if current is not None:
current_ranked = current[0] or current[1]
if ranked is None or (
current_ranked is not None
and parse_version(ranked) <= parse_version(current_ranked)
):
continue
best[package_id] = (installed, latest)
for package_id, (installed, latest) in best.items():
yield self.package(
id=package_id,
installed_version=installed,
latest_version=latest,
)
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ luarocks --no-project list --porcelain
inspect 3.1.2-0 installed /Users/kde/.luarocks/lib/luarocks/rocks-5.5
say 1.4.1-3 installed /Users/kde/.luarocks/lib/luarocks/rocks-5.5
```
"""
output = self.run_cli("list", "--porcelain")
yield from self._newest_per_rock(
(match.group("package_id"), match.group("installed_version"), None)
for match in map(self._INSTALLED_REGEXP.match, output.splitlines())
if match
)
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages.
```{code-block} shell-session
$ luarocks --no-project list --outdated --porcelain
inspect 3.1.2-0 3.1.3-0 https://luarocks.org
```
"""
output = self.run_cli("list", "--outdated", "--porcelain")
yield from self._newest_per_rock(
(
match.group("package_id"),
match.group("installed_version"),
match.group("latest_version"),
)
for match in map(self._OUTDATED_REGEXP.match, output.splitlines())
if match
)
[docs]
@search_capabilities(extended_support=False, exact_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
```{caution}
Search does not support extended or exact matching.
```
```{code-block} shell-session
$ luarocks --no-project search say --porcelain
say 1.4.1-3 rockspec https://luarocks.org
say 1.4.1-3 src https://luarocks.org
say 1.4.0-1 rockspec https://luarocks.org
say 1.4.0-1 src https://luarocks.org
say 1.3-1 rockspec https://luarocks.org
```
"""
output = self.run_cli("search", query, "--porcelain")
yield from self._newest_per_rock(
(match.group("package_id"), None, match.group("latest_version"))
for match in map(self._SEARCH_REGEXP.match, output.splitlines())
if match
)
[docs]
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
```{code-block} shell-session
$ luarocks --no-project install inspect
inspect 3.1.3-0 depends on lua >= 5.1 (5.5-1 provided by VM: success)
No existing manifest. Attempting to rebuild...
inspect 3.1.3-0 is now installed in /opt/homebrew (license: MIT <http://opensource.org/licenses/MIT>)
```
"""
spec = (package_id,) if version is None else (package_id, version)
return self.run_cli("install", *spec)
[docs]
def upgrade_one_cli(
self,
package_id: str,
version: str | None = None,
) -> tuple[str, ...]:
"""Generates the CLI to upgrade one package.
LuaRocks has no `upgrade` verb: installing a rock that is already
present replaces it with the newer build, which is the upgrade.
```{code-block} shell-session
$ luarocks --no-project install inspect
inspect 3.1.3-0 depends on lua >= 5.1 (5.5-1 provided by VM: success)
No existing manifest. Attempting to rebuild...
inspect 3.1.3-0 is now installed in /opt/homebrew (license: MIT <http://opensource.org/licenses/MIT>)
```
"""
spec = (package_id,) if version is None else (package_id, version)
return self.build_cli("install", *spec)
[docs]
def remove(self, package_id: str) -> str:
"""Remove one package.
Acts on the default tree, so a rock held in another one is refused by
name rather than removed, as covered in the class notes above.
```{code-block} shell-session
$ luarocks --no-project remove inspect
Checking stability of dependencies in the absence of
inspect 3.1.3-0...
Removing inspect 3.1.3-0...
Removal successful.
```
"""
return self.run_cli("remove", package_id)