# 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 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.
Documentation: [`pkg(1)` man page](https://docs.oracle.com/cd/E88353_01/html/E37839/pkg-1.html).
The CLI is named `pkg`, which the FreeBSD {class}`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}
`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.
```
```{todo}
`outdated` is not implemented. The operation needs a sample naming both the
installed and the available version, and the only illumos host available
reported `no packages have newer versions available`.
That 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.
Capture it on a host whose image has fallen behind its publisher, which is
the only state that emits the output.
```
"""
name = "Image Packaging System"
homepage_url = "https://github.com/OpenIndiana/pkg5"
# 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
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+(?P<version>\S+)\s+[a-z-]{3}$",
re.MULTILINE,
)
"""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<version>\S+)\s+\S+$",
re.MULTILINE,
)
"""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")
for match in self._LIST_REGEXP.finditer(output):
yield self.package(
id=match.group("package_id"),
installed_version=match.group("version"),
)
[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:/[email protected] openindiana.org
pkg:/archiver/[email protected] openindiana.org
pkg:/archiver/[email protected] openindiana.org
pkg:/developer/[email protected] openindiana.org
pkg:/library/perl-5/[email protected] openindiana.org
```
"""
output = self.run_cli("search", "-H", "-p", query)
for match in self._SEARCH_REGEXP.finditer(output):
yield self.package(
id=match.group("package_id"),
latest_version=match.group("version"),
)
[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(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.
```{code-block} shell-session
$ sudo pkg purge-history
```
"""
self.run_cli("purge-history", sudo=True)