Source code for meta_package_manager.managers.cpan

# 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 os
import re
from pathlib import Path
from typing import ClassVar

from extra_platforms import ALL_PLATFORMS

from ..capabilities import version_not_implemented
from ..manager import PackageManager

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

    from ..package import Package


[docs] class CPAN(PackageManager): """The cpan client of the Comprehensive Perl Archive Network. The tracked version is CPAN.pm's library version, not the cpan script version printed on the same line. Installs may require `sudo` when using the system Perl; `local::lib` gives user-local installs instead. The write operations carry dormant privileged markers: `--sudo` or a `sudo = true` override escalates them, while nothing escalates by default. cpan cannot uninstall a module, so no remove operation is declared. ```{caution} Perl's core modules (`warnings`, `feature`, `POSIX`, `B`, and the like) exist only inside Perl itself, with no standalone CPAN distribution, so their versions are tied to whichever Perl you run. `cpan -O` reports them as outdated whenever that Perl trails the newest one CPAN has indexed, yet `cpan -u` can never upgrade them in place, since only a newer Perl carries newer core modules. `mpm` relays both faithfully. This holds for any Perl, whether the macOS system one or a Homebrew install. On the macOS system Perl at `/usr/bin/perl`, that Perl sits on the sealed, read-only system volume and cannot be replaced even with `sudo`, so the list never clears. With a writable, user-controlled Perl ahead on your `PATH` (via `brew install perl`, since `mpm` drives whichever `cpan` comes first), the list does clear, but on a recurring delay: each yearly Perl release reaches CPAN before Homebrew publishes its bottle, so for a few weeks `cpan -O` flags the new release's core modules as outdated against your current ones. Perl `5.44.0`, released 2026-07-15, opened one such window against the `5.42` series Homebrew still bottled. Running `brew upgrade perl` once the new bottle lands moves your core modules to the new set and clears the list, until the next release reopens the window. If you do not track Perl modules with `cpan` at all, [disable the manager entirely](#selecting-and-configuring-cpan): set `cpan = false` in your mpm config, or pass `--no-cpan`. See [#1983](https://github.com/kdeldycke/meta-package-manager/issues/1983) for the original report. ``` ```{note} On Debian and Ubuntu, Perl reaches its core modules through `@INC` directories that are symlinks, like `/usr/share/perl/5.40` pointing to `5.40.1`. `cpan -l` does not enter a directory it is given as a symlink, so it lists none of those modules, while `cpan -O` still reports them as outdated. `mpm` therefore runs `cpan -l` a second time with the resolved directories in `PERL5LIB`, and adds the modules that only this second run finds. ``` """ name = "Perl CPAN" homepage_url = "https://www.cpan.org" logo = "perl" keywords = ("perl",) platforms = ALL_PLATFORMS requirement = ">=1.64" extra_env: ClassVar = {"PERL_MM_USE_DEFAULT": "1"} """Suppress the interactive configuration cpan may launch on first run.""" version_cli_options = ("-v",) version_regexes = (r"CPAN\.pm\s+version\s+(?P<version>\S+)",) """Read the CPAN.pm version, the meaningful one, rather than the script's. ```{code-block} shell-session $ cpan -v Loading internal logger. Log::Log4perl recommended for better logging >(info): /usr/bin/cpan script version 1.678, CPAN.pm version 2.36 ``` """ _INSTALLED_REGEXP = re.compile( r"^(?P<package_id>\S+)\t(?:undef|(?P<installed_version>\S+))$", ) """One `Module<TAB>version` row of `cpan -l`. A module with no version reports the literal `undef`, which the alternation keeps out of the version group, so the module is yielded version-less. """ _OUTDATED_REGEXP = re.compile( r"^(?P<package_id>\S+)\s{2,}(?P<installed_version>\d+\.\d+)" r"\s{2,}(?P<latest_version>\d+\.\d+)$", ) """One row of the `cpan -O` report. App::Cpan prints each row with `printf "%-40s %.4f %.4f\\n"`, so a real row separates its columns by at least two spaces and both versions are numeric. Both halves of that shape are required, because a bare three-token match also catches the progress line CPAN::FTP prints while it refreshes its index, `Fetching with HTTP::Tiny:`, which arrived in the menu as a package named `Fetching` upgrading from `with` to `HTTP::Tiny:`. The banner, the `Reading` line, the indented `Database was generated` line, the column header and the dashed separator fail on the same two counts. """ _INC_PROBE = 'print "$_\\n" for @INC' """Perl one-liner printing `@INC`, one directory per line.""" @property def installed(self) -> Iterator[Package]: """Fetch installed modules. ```{code-block} shell-session $ cpan -l Loading internal logger. Log::Log4perl recommended for better logging Glib 1.3294 Cairo 1.109 Clone 0.47 Text::CharWidth 0.04 Text::Iconv 1.7 HTML::TokeParser 3.83 HTML::PullParser 3.83 HTML::Entities 3.83 HTML::Parser 3.83 HTML::LinkExtor 3.83 HTML::Filter 3.83 HTML::HeadParser 3.83 Net::SSLeay 1.94 Net::DBus 1.2.0 Net::DBus::Error undef Net::DBus::Callback undef Net::DBus::Reactor undef ``` `cpan -l` walks each `@INC` directory with `File::Find`, which does not enter a starting directory that is a symlink. When `_symlinked_inc_targets()` finds such directories, a second run puts their resolved paths in `PERL5LIB`, which `cpan -l` then walks too: ```{code-block} console $ PERL5LIB=/usr/share/perl/5.40.1:/usr/lib/aarch64-linux-gnu/perl/5.40.1 cpan -l Loading internal logger. Log::Log4perl recommended for better logging UNIVERSAL 1.17 version 0.9930 English 1.11 _charnames 1.50 fields 2.25 ``` From that second run, only the modules the first one missed are kept. `PERL5LIB` puts the resolved directories ahead of the rest of `@INC`, and Debian's `perl-base` package ships a second copy of some core modules, like `strict` and `warnings`: keeping every row would list those modules twice. ```{todo} Drop the second run, with `_sibling_perl()` and `_symlinked_inc_targets()`, once a fixed `cpan -l` reaches the Perl that Debian and Ubuntu ship. Reported upstream as [andk/cpanpm#202](https://github.com/andk/cpanpm/issues/202). ``` """ listed: set[str] = set() for package in self.parse_regex_lines( self._INSTALLED_REGEXP, self.run_cli("-l") ): listed.add(package.id) yield package targets = self._symlinked_inc_targets() if not targets: return # PERL5LIB replaces the inherited value, so the user's own entries (a # local::lib tree) are kept after the resolved directories. search_path = [*targets] if os.environ.get("PERL5LIB"): search_path.append(os.environ["PERL5LIB"]) output = self.run_cli( "-l", override_extra_env={ **self.extra_env, "PERL5LIB": os.pathsep.join(search_path), }, ) for package in self.parse_regex_lines(self._INSTALLED_REGEXP, output): if package.id not in listed: listed.add(package.id) yield package @property def outdated(self) -> Iterator[Package]: """Fetch outdated modules. ```{code-block} shell-session $ cpan -O Loading internal logger. Log::Log4perl recommended for better logging Reading '/home/user/.cpan/Metadata' Database was generated on Wed, 16 Sep 2026 14:17:02 GMT Module Name Local CPAN ------------------------------------------------------------------------- App::Cpan 1.6780 1.6790 App::Prove 3.4800 3.5200 App::Prove::State 3.4800 3.5200 App::Prove::State::Result 3.4800 3.5200 App::Prove::State::Result::Test 3.4800 3.5200 Archive::Tar 3.0200 3.1200 Archive::Tar::Constant 3.0200 3.1200 ``` """ output = self.run_cli("-O") yield from self.parse_regex_lines(self._OUTDATED_REGEXP, output)
[docs] @version_not_implemented def install(self, package_id: str, version: str | None = None) -> str: """Install one module. A bare module name installs it. ```{code-block} shell-session $ cpan Try::Tiny ``` """ return self.run_cli(package_id, sudo=True)
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generate the CLI to upgrade all modules. ```{code-block} shell-session $ cpan -u ``` """ return self.build_cli("-u", sudo=True)
[docs] @version_not_implemented def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Generate the CLI to upgrade one module. ```{code-block} shell-session $ cpan -i Try::Tiny ``` """ return self.build_cli("-i", package_id, sudo=True)
def _sibling_perl(self) -> Path | None: """The `perl` installed beside the resolved `cpan`, or `None`. Perl installs its `cpan` script in the same directory as the `perl` binary: `installscript` and `installbin` are both `/usr/bin` on Ubuntu and on macOS. That binary's `@INC` is therefore the one `cpan -l` walks. """ if self.cli_path is None: return None for name in ("perl", "perl.exe"): candidate = self.cli_path.parent / name if candidate.is_file(): return candidate return None def _symlinked_inc_targets(self) -> tuple[str, ...]: """Resolved paths of the `@INC` directories `cpan -l` cannot walk. An `@INC` entry that is a symlink to a directory is resolved, unless another entry already reaches its target. The result is empty when no `perl` sits beside `cpan` to ask. ```{code-block} console $ perl -e 'print "$_\\n" for @INC' /etc/perl /usr/local/lib/aarch64-linux-gnu/perl/5.40.1 /usr/local/share/perl/5.40.1 /usr/lib/aarch64-linux-gnu/perl5/5.40 /usr/share/perl5 /usr/lib/aarch64-linux-gnu/perl-base /usr/lib/aarch64-linux-gnu/perl/5.40 /usr/share/perl/5.40 /usr/local/lib/site_perl ``` """ perl = self._sibling_perl() if perl is None: return () output = self.run_cli( "-e", self._INC_PROBE, override_cli_path=perl, auto_extra_env=False, ) walked: set[Path] = set() linked: list[Path] = [] for line in output.splitlines(): entry = Path(line) if not entry.is_absolute(): continue if entry.is_symlink(): linked.append(entry) elif entry.is_dir(): walked.add(entry.resolve()) targets: list[str] = [] for entry in linked: target = entry.resolve() if target.is_dir() and target not in walked and str(target) not in targets: targets.append(str(target)) return tuple(targets)