# 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
import re
from typing import ClassVar
from extra_platforms import ILLUMOS, SOLARIS
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
[docs]
class IPS(PackageManager):
"""The Image Packaging System, the native `pkg` of Solaris and illumos.
The CLI is named `pkg`, which the FreeBSD {class}`~meta_package_manager.managers.pkg.PKG` manager also claims.
The two never coexist, their `platforms` being disjoint, but the manager ID
here is `ips` so the two stay addressable apart.
```{note}
Only root may modify the image, so mutating operations escalate through
`sudo`. `sync` escalates too: refreshing the catalog writes into the
image's own metadata rather than a user cache.
```
```{caution}
The inventory is a local read: the catalog is not refreshed, so the
listing works offline.
```
```{todo}
Implement `outdated`, on a host whose image has fallen behind its
publisher: that is the only state emitting output that names both the
installed and the available version, and the one illumos host available
reported `no packages have newer versions available`.
The state cannot be manufactured on a consistent image, so do not spend
time trying: installing a superseded build to force one is refused with
`did not match any allowable packages`, the release incorporations
constraining an image to one allowable version per package. Inventing a
fixture is not an option either, a sample having to parse through this
manager's own parser and having to be real.
```
"""
# `installed` passes `--no-refresh` to keep the inventory a local read.
# Left off, `pkg list` contacts every configured publisher first, which
# turns a listing into a network round-trip and fails outright when the
# host is offline.
#
# `outdated` is not implemented: the class docstring carries the sample
# the operation waits on, as a `{todo}` the published list collects.
operation_notes: ClassVar = {
"outdated": (
"Awaiting a real sample naming both the installed and the "
"available version, which only a host whose image has fallen "
"behind its publisher emits."
),
}
name = "Image Packaging System"
documentation_url = "https://docs.oracle.com/cd/E88353_01/html/E37839/pkg-1.html"
repository_url = "https://github.com/OpenIndiana/pkg5"
wikipedia_url = "https://en.wikipedia.org/wiki/Image_Packaging_System"
# No `logo`, and there never will be: Oracle's marks were pulled from Simple
# Icons, so do not file a request, and do not vendor one by hand:
# https://github.com/simple-icons/simple-icons/issues/11441
keywords = ("pkg5",)
"""The name the upstream project answers to, which the `ips` ID does not carry."""
platforms = ILLUMOS, SOLARIS
default_sudo = True
privileged_operations = frozenset(
{"cleanup", "install", "remove", "sync", "upgrade", "upgrade_all"},
)
cli_names = ("pkg",)
version_cli_options = ("list", "-H", "--no-refresh", "package/pkg")
"""IPS reports no version of its own: `pkg --version` is answered with
``pkg: illegal global option -- version`` and exit `2`, while bare
`pkg version` prints the build hash alone (`4e1d42c7`), which carries no
ordering. IPS does package itself as `package/pkg` on every distribution,
so its own inventory row is the version, and `--no-refresh` keeps the probe
from reaching the network.
"""
version_regexes = (r"package/pkg\s+(?P<version>\S+)",)
"""
```{code-block} shell-session
$ pkg list -H --no-refresh package/pkg
package/pkg 0.5.11-2025.0.0.5626 i--
```
"""
_LIST_REGEXP = re.compile(
r"^(?P<package_id>\S+)(?:\s+\([^)]+\))?\s+"
r"(?P<installed_version>\S+)\s+[a-z-]{3}$",
)
"""Matches one `pkg list` row, anchored on the three-character `IFO` column.
The anchor is what keeps prose out of the results: `pkg list -u` answers
`no packages have newer versions available` on a zero exit, and a looser
pattern would yield that sentence as a package.
The optional parenthesized group absorbs the publisher, which the `NAME`
column carries as `name (publisher)` whenever a package comes from other
than the preferred publisher.
"""
_SEARCH_REGEXP = re.compile(
r"^pkg:/(?P<package_id>[^@\s]+)@(?P<latest_version>\S+)\s+\S+$",
)
"""Matches one `pkg search -p` row: an FMRI and its publisher, tab-separated.
Keyed on `\\s+` rather than a literal tab, so the pattern survives a
docstring reindented by the formatter.
"""
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ pkg list -H --no-refresh
SUNWcs 0.5.11-2026.0.0.23029 i--
SUNWcsd 0.5.11-2026.0.0.23029 i--
archiver/gnu-tar 1.35-2023.0.0.0 i--
audio/audio-utilities 0.5.11-2026.0.0.23029 i--
audio/lame 3.100-2025.0.0.3 i--
```
"""
output = self.run_cli("list", "-H", "--no-refresh")
yield from self.parse_regex_lines(self._LIST_REGEXP, output)
[docs]
@search_capabilities(extended_support=False, exact_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Search packages.
`-p` reports one row per matching package instead of one per matching
action, which is what the bare form emits: a package whose summary and
description both match is otherwise returned twice.
IPS indexes the summary and description alongside the name, so a query
always searches them. That is wider than `mpm`'s default, and neither
capability is declared: `mpm` refilters the results itself, narrowing
the plain case and leaving the extended one alone.
```{code-block} shell-session
$ pkg search -H -p tar
pkg:/SUNWcs@0.5.11-2026.0.0.23029 openindiana.org
pkg:/archiver/gnu-tar@1.35-2023.0.0.0 openindiana.org
pkg:/archiver/ofarc@1.5.1-2026.0.0.0 openindiana.org
pkg:/developer/quilt@0.69-2026.0.0.2 openindiana.org
pkg:/library/perl-5/archive-tar@3.4-2025.0.0.1 openindiana.org
```
"""
output = self.run_cli("search", "-H", "-p", query)
yield from self.parse_regex_lines(self._SEARCH_REGEXP, output)
[docs]
@version_not_implemented
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
`--accept` accepts the package licenses, which IPS would otherwise stop
and prompt for, hanging the subprocess.
```{code-block} shell-session
$ sudo pkg install --accept archiver/gnu-tar
```
"""
return self.run_cli("install", "--accept", package_id, sudo=True)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Upgrade all packages.
```{code-block} shell-session
$ sudo pkg update --accept
```
"""
return self.build_cli("update", "--accept", sudo=True)
[docs]
@version_not_implemented
def upgrade_one_cli(
self,
package_id: str,
version: str | None = None,
) -> tuple[str, ...]:
"""Upgrade one package.
```{code-block} shell-session
$ sudo pkg update --accept archiver/gnu-tar
```
"""
return self.build_cli("update", "--accept", package_id, sudo=True)
[docs]
def remove(self, package_id: str) -> str:
"""Remove one package.
```{code-block} shell-session
$ sudo pkg uninstall archiver/gnu-tar
```
"""
return self.run_cli("uninstall", package_id, sudo=True)
[docs]
def sync(self) -> None:
"""Refresh the publishers' catalogs.
```{code-block} shell-session
$ sudo pkg refresh
```
"""
self.run_cli("refresh", sudo=True)
[docs]
def cleanup_cache(self) -> None:
"""Purge the image's operation history.
IPS caches nothing a user can reclaim: downloaded content lands straight
in the image, so `purge-history` is the only space this manager frees,
and `mpm cleanup --cache` is the category that reaches it.
```{code-block} shell-session
$ sudo pkg purge-history
```
"""
self.run_cli("purge-history", sudo=True)