Package: command-not-found
Version: 23.04.0-3
Severity: wishlist
Tags: patch
Dear Maintainer,
Debian installs sharkdp's fd as /usr/bin/fdfind and bat as /usr/bin/batcat,
because fdclone has /usr/bin/fd and bacula-console-qt has /usr/sbin/bat (Policy
10.1).
The renames stay: #1051647 (please ship a fd binary) is wontfix since
2026-09-01, and forky still ships /usr/bin/batcat after #1029096.
command-not-found reads only the Contents index, so a person who types `fd` is
told to install fdclone, and `bat` offers bacula-console-qt:
$ fd
Command 'fd' not found, but can be installed with:
sudo apt install fdclone
The attached patch reads a small table in the data directory,
/usr/share/command-not-found/renamed-commands, with four fields: the command,
the package, the name Debian installs it as, and "first" or "after".
It names the renamed package with the name to run, before or after the packages
that have the command itself, and those stay listed:
$ fd
Command 'fd' not found, but can be installed with:
sudo apt install fd-find # run it as 'fdfind'
sudo apt install fdclone
$ ts
Command 'ts' not found, but can be installed with:
sudo apt install moreutils
sudo apt install task-spooler # run it as 'tsp'
A command with no row is advised exactly as before.
A row whose package the database does not have is not offered.
When the renamed program is already installed, the message says so in its place.
When the table cannot be read (missing, unreadable, or a broken row), one line
says so with the way to restore it, and the command is advised as without it.
The table has nine rows, each resting on the package's own README.Debian or
changelog.
First: antlr (runantlr), bat (batcat), cl (iraf's irafcl) and fd (fdfind).
After the packages that have the name: cal (ccal), spline (aspline), ts (tsp),
wipe (lamwipe) and xsd (xsdcxx).
A script makes it from forky's Contents, Packages and popularity contest; the
popularity contest decides first or after (more installations than every
package that has the name, or fewer).
Tests: nine new ones beside the existing ones; six of them fail on 23.04.0-3,
and all 21 pass with the patch, pyflakes clean.
The existing output tests now read the source tree's data directory, which
holds the table.
In a Debian testing container, in bash, zsh and fish (interactive and in a pty
session), `fd` and `bat` offered fdclone and bacula-console-qt with 23.04.0-3,
and fd-find and bat first with the patch.
`ts` and `cal` keep moreutils and ncal first and now also name task-spooler and
ccal.
300 sampled commands and 23 words without a row gave byte-identical output
before and after.
This work was done with AI assistance (Claude Opus 5.5), directed and reviewed
by me.
Daniel Campos Ramos
-- System Information:
Debian Release: forky/sid
APT prefers testing
APT policy: (500, 'testing')
Architecture: amd64 (x86_64)
Kernel: Linux 7.3.1-stereo3d14 (SMP w/12 CPU threads; PREEMPT)
Kernel taint flags: TAINT_OOT_MODULE, TAINT_UNSIGNED_MODULE
Locale: LANG=C, LC_CTYPE=C.UTF-8 (charmap=UTF-8), LANGUAGE not set
Shell: /bin/sh linked to /usr/bin/dash
Init: unable to detect
Versions of packages command-not-found depends on:
ii apt-file 3.4
ii lsb-release 12.1-2
ii python3 3.14.7-3
ii python3-apt 3.1.0+b2
command-not-found recommends no packages.
Versions of packages command-not-found suggests:
pn snapd <none>
-- no debconf information
Description: Name the programs Debian installs under another name
Debian Policy 10.1: "Two different packages must not install programs with
different functionality but with the same filenames ... If this case happens,
one of the programs must be renamed." So `fd` is fd-find's `fdfind`
(fdclone has /usr/bin/fd) and `bat` is bat's `batcat` (bacula-console-qt has
/usr/sbin/bat), and command-not-found, which reads only the Contents index,
offered fdclone for `fd` and bacula-console-qt for `bat`.
.
The data directory's renamed-commands file lists those programs: the command,
the package, the name Debian installs it as, and "first" or "after". For a
listed command, advise() names the package with the name to run, before the
packages and snaps that have the command under its own name ("first") or
after them ("after"); those stay listed as before. A package the database
does not have is not offered; one whose program is installed is said to be
installed. A command with no row is advised exactly as before. A table that
cannot be read (missing, unreadable, a broken row) is said in one line, with
the way to restore it, and the command is advised as without the table.
The upstream tests now read the source tree's own data directory.
.
Ubuntu's priority.txt (Michael Vogt, 2009, LP: #318442) ordered packages that
have the command itself and left with the sqlite database in 2018; neither
project has a renamed-commands table (searched 2026-11-02: Launchpad and
salsa main, merge requests, Debian's and Launchpad's bugs).
The three new messages ship in pl, pt_BR, fr, de, ja, ko, zh_CN and zh_TW.
Author: Daniel Campos Ramos <[email protected]>
Forwarded: no
Last-Update: 2026-11-02
---
CommandNotFound/CommandNotFound.py | 133 +++++++++++++++++-
CommandNotFound/tests/test_command_not_found.py | 3 +-
CommandNotFound/tests/test_renamed_commands.py | 172 ++++++++++++++++++++++++
po/command-not-found.pot | 15 +++
po/de.po | 15 +++
po/fr.po | 15 +++
po/ja.po | 15 +++
po/ko.po | 15 +++
po/pl.po | 15 +++
po/pt_BR.po | 15 +++
po/zh_CN.po | 15 +++
po/zh_TW.po | 15 +++
setup.py | 1 +
13 files changed, 442 insertions(+), 2 deletions(-)
diff --git a/CommandNotFound/CommandNotFound.py
b/CommandNotFound/CommandNotFound.py
index 74f1b89..3c9f811 100644
--- a/CommandNotFound/CommandNotFound.py
+++ b/CommandNotFound/CommandNotFound.py
@@ -70,9 +70,13 @@ class CommandNotFound(object):
snap_cmd = "/usr/bin/snap"
+ # commands Debian installs under another name (see get_renamed)
+ renamed_file = "renamed-commands"
+
output_fd = sys.stderr
def __init__(self, data_dir="/usr/share/command-not-found"):
+ self.data_dir = data_dir
self.sources_list = self._getSourcesList()
# a new style DB means we can skip loading the old legacy static DB
if os.path.exists(dbpath) and os.access(dbpath, os.R_OK):
@@ -96,6 +100,61 @@ class CommandNotFound(object):
def get_packages(self, command):
return self.db.lookup(command)
+ def get_renamed(self, command):
+ """
+ return the packages that install this command under another name
+
+ Debian Policy 10.1: of two programs with one name, "one of the
+ programs must be renamed". The renamed-commands file in the data
+ directory lists those programs, one per line: the command as its
+ upstream names it, the package, the name Debian installs it as, and
+ "first" or "after": whether the package is named before the packages
+ that have the command itself or after them.
+ Each result is (package, version, component, installed name, path,
+ first): path is where the installed name already is, otherwise None
+ and the package is one the database has for the installed name.
+ A table that cannot be read is said, and no package is renamed.
+ """
+ path = os.path.join(self.data_dir, self.renamed_file)
+ try:
+ with open(path) as fp:
+ lines = fp.readlines()
+ except (IOError, OSError) as e:
+ self.print_renamed_problem(path, e.strerror or str(e))
+ return []
+ rows = []
+ for number, line in enumerate(lines, 1):
+ if not line.strip() or line.lstrip().startswith("#"):
+ continue
+ row = line.split()
+ if len(row) != 4 or row[3] not in ("first", "after"):
+ self.print_renamed_problem(path, "line %d: %s" % (number,
line.strip()))
+ return []
+ rows.append(row)
+ results = []
+ for (cmd, package, name, order) in rows:
+ if cmd != command:
+ continue
+ first = order == "first"
+ # where the shell finds it (PATH, so /usr/bin before a merged
/bin), then the other prefixes
+ folders = [d for d in os.getenv("PATH", "").split(":") if d in
self.prefixes]
+ folders += [prefix for prefix in self.prefixes if prefix not in
folders]
+ places = [os.path.join(folder, name) for folder in folders
+ if os.path.isfile(os.path.join(folder, name))]
+ if places:
+ results.append((package, None, None, name, places[0], first))
+ continue
+ for (pkg, ver, comp) in self.get_packages(name):
+ if pkg == package:
+ results.append((pkg, ver, comp, name, None, first))
+ break
+ return results
+
+ def print_renamed_problem(self, path, error):
+ print(_("The renamed-commands table %(path)s could not be read
(%(error)s); reinstalling command-not-found restores it.") % {
+ 'path': path, 'error': error}, file=self.output_fd)
+ self.output_fd.flush()
+
def get_snaps(self, command):
exact_result = []
mispell_result = []
@@ -293,7 +352,72 @@ class CommandNotFound(object):
else:
print(_("See 'snap info <snapname>' for additional versions."),
file=self.output_fd)
self.output_fd.flush()
-
+
+ def print_installed(self, command, renamed):
+ for (package, ver, comp, name, place, first) in renamed:
+ if place:
+ print(_("Command '%(command)s' is installed as '%(place)s'
from deb %(package)s") % {
+ 'command': command, 'place': place, 'package': package},
file=self.output_fd)
+
+ def advice_renamed(self, command, renamed, packages, snaps):
+ """
+ name each program Debian installs this command as before the
+ packages and snaps that have the command under its own name when its
+ row says "first", after them when it says "after"
+ """
+ first = [r for r in renamed if r[5]]
+ after = [r for r in renamed if not r[5]]
+ self.print_installed(command, first)
+ if all(r[4] for r in renamed):
+ # each renamed program is installed: the other packages as they
+ # are always advised
+ if len(packages) == 0 and len(snaps) == 1:
+ self.advice_single_snap_package(command, packages, snaps)
+ elif len(packages) == 0 and len(snaps) > 1:
+ self.advice_multi_snap_packages(command, packages, snaps)
+ elif len(packages) == 1 and len(snaps) == 0:
+ self.advice_single_deb_package(command, packages, snaps)
+ elif len(packages) > 1 and len(snaps) == 0:
+ self.advice_multi_deb_package(command, packages, snaps)
+ elif len(packages) > 0 and len(snaps) > 0:
+ self.advice_multi_mixed_packages(command, packages, snaps)
+ self.print_installed(command, after)
+ self.output_fd.flush()
+ return
+ self._print_exact_header(command)
+ rows = [(r[0], r[1], r[2], r[3]) for r in first if not r[4]]
+ rows += [(p[0], p[1], p[2], None) for p in packages]
+ rows += [(r[0], r[1], r[2], r[3]) for r in after if not r[4]]
+ pad = max([len(r[0]) for r in rows] + [len(s[0]) for s in snaps])
+ for (package, ver, comp, name) in rows:
+ notes = []
+ if name:
+ notes.append(_("run it as '%s'") % name)
+ if ver:
+ notes.append("version %s" % ver)
+ line = "%sapt install %-*s" % (self.sudo(), pad, package)
+ if notes:
+ line += " # " + ", ".join(notes)
+ if comp not in self.sources_list:
+ line += " (" + _("You will have to enable component called
'%s'") % comp + ")"
+ print(line.rstrip(), file=self.output_fd)
+ for snap in snaps:
+ ver = ""
+ if snap[2]:
+ ver = " # version %s" % snap[2]
+ print("%ssnap install %-*s%s" % (self.sudo(), pad, snap[0], ver),
file=self.output_fd)
+ if snaps:
+ print(_("See 'snap info <snapname>' for additional versions."),
file=self.output_fd)
+ if self.euid != 0 and not self.user_can_sudo:
+ if len(rows) + len(snaps) == 1:
+ print(_("Please ask your administrator."), file=self.output_fd)
+ else:
+ print(_("Ask your administrator to install one of them."),
file=self.output_fd)
+ elif len(rows) == 1 and not snaps:
+ self.install_prompt(rows[0][0])
+ self.print_installed(command, after)
+ self.output_fd.flush()
+
def advise(self, command, ignore_installed=False):
" give advice where to find the given command to stderr "
def _in_prefix(prefix, command):
@@ -348,6 +472,13 @@ class CommandNotFound(object):
packages = self.get_packages(command)
snaps, mispell_snaps = self.get_snaps(command)
logging.debug("got debs: %s snaps: %s" % (packages, snaps))
+ renamed = self.get_renamed(command)
+ if renamed:
+ logging.debug("got renamed debs: %s" % renamed)
+ names = set(r[0] for r in renamed)
+ packages = [p for p in packages if p[0] not in names]
+ self.advice_renamed(command, renamed, packages, snaps)
+ return True
if len(packages) == 0 and len(snaps) == 0:
mispell_packages = self.spelling_suggestions(command)
if len(mispell_packages) > 0 or len(mispell_snaps) > 0:
diff --git a/CommandNotFound/tests/test_command_not_found.py
b/CommandNotFound/tests/test_command_not_found.py
index 809809f..78973f4 100644
--- a/CommandNotFound/tests/test_command_not_found.py
+++ b/CommandNotFound/tests/test_command_not_found.py
@@ -115,7 +115,8 @@ class CommandNotFoundOutputTest(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.mock_db()
- self.cnf = CommandNotFound()
+ # the source tree's own data directory, which holds the
renamed-commands table
+ self.cnf = CommandNotFound(os.path.join(os.path.dirname(__file__),
"..", "..", "data"))
self.cnf.snap_cmd = os.path.join(self.tmpdir, "mock-snap-cmd-%i")
# FIXME: add this to the test spec to test the outputs for uid=0/1000
# and for sudo/no-sudo
diff --git a/CommandNotFound/tests/test_renamed_commands.py
b/CommandNotFound/tests/test_renamed_commands.py
new file mode 100644
index 0000000..bc09ba9
--- /dev/null
+++ b/CommandNotFound/tests/test_renamed_commands.py
@@ -0,0 +1,172 @@
+#!/usr/bin/python3
+
+import os
+import shutil
+import tempfile
+import unittest
+
+import CommandNotFound.CommandNotFound as CommandNotFound_Module
+from CommandNotFound.CommandNotFound import CommandNotFound
+from CommandNotFound.db.creator import DbCreator
+
+
+class MockAptDB:
+ def __init__(self, debs):
+ self._db = {}
+ for deb in debs.split(";"):
+ name, cmds = deb.split(":")
+ for cmd in cmds.split(","):
+ self._db.setdefault(cmd, []).append((name, None, "main"))
+
+ def lookup(self, command):
+ return self._db.get(command, [])
+
+
+TABLE = """\
+# command package installed as order
+bat bat batcat first
+fd fd-find fdfind first
+ts task-spooler tsp after
+"""
+
+PROBLEM = "The renamed-commands table %s could not be read (%s); reinstalling
command-not-found restores it.\n"
+
+
+class RenamedCommandsTest(unittest.TestCase):
+
+ def setUp(self):
+ self.tmpdir = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.tmpdir)
+ commands_file = os.path.join(self.tmpdir, "Commands-all")
+ with open(commands_file, "w"):
+ pass
+ DbCreator([commands_file]).create(os.path.join(self.tmpdir, "test.db"))
+ CommandNotFound_Module.dbpath = os.path.join(self.tmpdir, "test.db")
+ self.data_dir = os.path.join(self.tmpdir, "data")
+ os.mkdir(self.data_dir)
+ self.write_table(TABLE)
+ self.bindir = os.path.join(self.tmpdir, "bin")
+ os.mkdir(self.bindir)
+
+ def write_table(self, text):
+ with open(os.path.join(self.data_dir, "renamed-commands"), "w") as fp:
+ fp.write(text)
+
+ def advise(self, debs, command, euid=1000, sudo=True, data_dir=None):
+ cnf = CommandNotFound(data_dir or self.data_dir)
+ cnf.db = MockAptDB(debs)
+ cnf.snap_cmd = os.path.join(self.tmpdir, "no-snap")
+ cnf.prefixes = (self.bindir,)
+ cnf.sources_list = set(["main"])
+ cnf.euid = euid
+ cnf.user_can_sudo = sudo
+ cnf.output_fd = open(os.path.join(self.tmpdir, "output"), "w")
+ found = cnf.advise(command)
+ cnf.output_fd.close()
+ with open(cnf.output_fd.name) as fp:
+ return found, fp.read()
+
+ def test_renamed_first_literal_kept(self):
+ self.assertEqual(self.advise("fdclone:fd;fd-find:fdfind", "fd"),
(True, """\
+Command 'fd' not found, but can be installed with:
+sudo apt install fd-find # run it as 'fdfind'
+sudo apt install fdclone
+"""))
+ self.assertEqual(self.advise("bacula-console-qt:bat;bat:batcat",
"bat"), (True, """\
+Command 'bat' not found, but can be installed with:
+sudo apt install bat # run it as 'batcat'
+sudo apt install bacula-console-qt
+"""))
+
+ def test_renamed_after_literal(self):
+ self.assertEqual(self.advise("moreutils:ts;task-spooler:tsp", "ts"),
(True, """\
+Command 'ts' not found, but can be installed with:
+sudo apt install moreutils
+sudo apt install task-spooler # run it as 'tsp'
+"""))
+
+ def test_renamed_only(self):
+ self.assertEqual(self.advise("fd-find:fdfind", "fd"), (True, """\
+Command 'fd' not found, but can be installed with:
+sudo apt install fd-find # run it as 'fdfind'
+"""))
+
+ def test_renamed_installed(self):
+ open(os.path.join(self.bindir, "fdfind"), "w").close()
+ self.assertEqual(self.advise("fdclone:fd;fd-find:fdfind", "fd"),
(True, """\
+Command 'fd' is installed as '%s/fdfind' from deb fd-find
+Command 'fd' not found, but can be installed with:
+sudo apt install fdclone
+""" % self.bindir))
+ self.assertEqual(self.advise("fd-find:fdfind", "fd"), (True, """\
+Command 'fd' is installed as '%s/fdfind' from deb fd-find
+""" % self.bindir))
+ open(os.path.join(self.bindir, "tsp"), "w").close()
+ self.assertEqual(self.advise("moreutils:ts;task-spooler:tsp", "ts"),
(True, """\
+Command 'ts' not found, but can be installed with:
+sudo apt install moreutils
+Command 'ts' is installed as '%s/tsp' from deb task-spooler
+""" % self.bindir))
+
+ def test_renamed_package_not_in_the_archive(self):
+ # a row whose package the archive does not have is not offered
+ self.assertEqual(self.advise("fdclone:fd", "fd"), (True, """\
+Command 'fd' not found, but can be installed with:
+sudo apt install fdclone
+"""))
+
+ def test_no_row_unchanged(self):
+ self.assertEqual(self.advise("vim:x-vi;neovim:x-vi", "x-vi"), (True,
"""\
+Command 'x-vi' not found, but can be installed with:
+sudo apt install vim""" + " " + """
+sudo apt install neovim
+"""))
+
+ def test_table_that_cannot_be_read_is_said(self):
+ # missing, unreadable (a folder in its place, which root cannot read
either) and a broken row: the problem is
+ # said, and the command is advised as without the table
+ literal = """\
+Command 'fd' not found, but can be installed with:
+sudo apt install fdclone
+"""
+ path = os.path.join(self.data_dir, "renamed-commands")
+ missing = os.path.join(self.tmpdir, "missing")
+ self.assertEqual(self.advise("fdclone:fd;fd-find:fdfind", "fd",
data_dir=missing), (True,
+ PROBLEM % (os.path.join(missing, "renamed-commands"),
"No such file or directory") + literal))
+ os.remove(path)
+ os.mkdir(path)
+ self.assertEqual(self.advise("fdclone:fd;fd-find:fdfind", "fd"),
(True, PROBLEM % (path, "Is a directory") + literal))
+ os.rmdir(path)
+ self.write_table(TABLE + "cal\tccal\tccal\n")
+ self.assertEqual(self.advise("fdclone:fd;fd-find:fdfind", "fd"),
(True, PROBLEM % (path, "line 5: cal\tccal\tccal") + literal))
+
+ def test_without_sudo(self):
+ self.assertEqual(self.advise("fdclone:fd;fd-find:fdfind", "fd",
sudo=False), (True, """\
+Command 'fd' not found, but can be installed with:
+apt install fd-find # run it as 'fdfind'
+apt install fdclone
+Ask your administrator to install one of them.
+"""))
+ self.assertEqual(self.advise("fd-find:fdfind", "fd", euid=0), (True,
"""\
+Command 'fd' not found, but can be installed with:
+apt install fd-find # run it as 'fdfind'
+"""))
+
+ def test_shipped_table(self):
+ path = os.path.join(os.path.dirname(__file__), "..", "..", "data",
"renamed-commands")
+ rows = {}
+ with open(path) as fp:
+ for line in fp:
+ if line.strip() and not line.lstrip().startswith("#"):
+ fields = line.split()
+ self.assertEqual(len(fields), 4, line)
+ self.assertIn(fields[3], ("first", "after"), line)
+ self.assertNotIn(fields[0], rows, line)
+ rows[fields[0]] = tuple(fields[1:])
+ self.assertEqual(rows["fd"], ("fd-find", "fdfind", "first"))
+ self.assertEqual(rows["bat"], ("bat", "batcat", "first"))
+ self.assertEqual(rows["ts"], ("task-spooler", "tsp", "after"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/po/command-not-found.pot b/po/command-not-found.pot
index 7c1d617..e8a25ab 100644
--- a/po/command-not-found.pot
+++ b/po/command-not-found.pot
@@ -161,3 +161,18 @@ msgstr ""
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr ""
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr ""
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr ""
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr ""
diff --git a/po/de.po b/po/de.po
index 1986997..66677bb 100644
--- a/po/de.po
+++ b/po/de.po
@@ -174,3 +174,18 @@ msgstr "Python-Version: %d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "Informationen zum Fehler:"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "als »%s« aufrufen"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "Befehl »%(command)s« ist als »%(place)s« aus dem deb %(package)s
installiert"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "Die Tabelle der umbenannten Befehle %(path)s konnte nicht gelesen
werden (%(error)s); eine Neuinstallation von command-not-found stellt sie
wieder her."
diff --git a/po/fr.po b/po/fr.po
index 5fa671a..284d195 100644
--- a/po/fr.po
+++ b/po/fr.po
@@ -170,3 +170,18 @@ msgstr "Version de python : %d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "Information sur l'exception :"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "exécutez-la sous le nom « %s »"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "La commande « %(command)s » est installée sous le nom « %(place)s »
par le deb %(package)s"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "Impossible de lire la table des commandes renommées %(path)s
(%(error)s) ; réinstaller command-not-found la rétablit."
diff --git a/po/ja.po b/po/ja.po
index 0b11359..4785bbc 100644
--- a/po/ja.po
+++ b/po/ja.po
@@ -167,3 +167,18 @@ msgstr "Python バージョン: %d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "例外情報:"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "'%s' として実行してください"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "コマンド '%(command)s' は deb %(package)s から '%(place)s' としてインストールされています"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "名前が変更されたコマンドの表 %(path)s を読み込めませんでした (%(error)s)。command-not-found
を再インストールすると元に戻ります。"
diff --git a/po/ko.po b/po/ko.po
index 0ae2421..48bcfe3 100644
--- a/po/ko.po
+++ b/po/ko.po
@@ -169,3 +169,18 @@ msgstr "파이선 버전: %d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "예외 정보:"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "'%s'(으)로 실행하십시오"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "'%(command)s' 명령은 deb %(package)s에서 '%(place)s'(으)로 설치되어 있습니다"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "이름이 바뀐 명령 표 %(path)s을(를) 읽을 수 없습니다(%(error)s). command-not-found를 다시
설치하면 복구됩니다."
diff --git a/po/pl.po b/po/pl.po
index 061259d..8a84fa6 100644
--- a/po/pl.po
+++ b/po/pl.po
@@ -170,3 +170,18 @@ msgstr "Python wersja: %d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "Informacje o błędzie:"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "uruchom jako '%s'"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "Polecenie '%(command)s' jest zainstalowane jako '%(place)s' z pakietu
deb %(package)s"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "Nie można odczytać tabeli przemianowanych poleceń %(path)s
(%(error)s); ponowna instalacja command-not-found ją przywróci."
diff --git a/po/pt_BR.po b/po/pt_BR.po
index 8b70374..caca9c5 100644
--- a/po/pt_BR.po
+++ b/po/pt_BR.po
@@ -168,3 +168,18 @@ msgstr "Versão de Python: %d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "Informação de exceção:"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "execute-o como '%s'"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "O comando '%(command)s' está instalado como '%(place)s', do pacote deb
%(package)s"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "Não foi possível ler a tabela de comandos renomeados %(path)s
(%(error)s); reinstalar o command-not-found a restaura."
diff --git a/po/zh_CN.po b/po/zh_CN.po
index c452e34..1a55960 100644
--- a/po/zh_CN.po
+++ b/po/zh_CN.po
@@ -163,3 +163,18 @@ msgstr "Python 版本:%d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "额外信息:"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "以 “%s” 运行"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "命令 “%(command)s” 已由 Debian 软件包 %(package)s 安装为 “%(place)s”"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "无法读取重命名命令表 %(path)s(%(error)s);重新安装 command-not-found 即可恢复。"
diff --git a/po/zh_TW.po b/po/zh_TW.po
index 43631e2..52e5878 100644
--- a/po/zh_TW.po
+++ b/po/zh_TW.po
@@ -162,3 +162,18 @@ msgstr "Python 版本:%d.%d.%d %s %d"
#: ../CommandNotFound/util.py:36
msgid "Exception information:"
msgstr "例外訊息:"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, c-format
+msgid "run it as '%s'"
+msgstr "以 '%s' 執行"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "Command '%(command)s' is installed as '%(place)s' from deb %(package)s"
+msgstr "'%(command)s' 指令已由 deb %(package)s 安裝為 '%(place)s'"
+
+#: ../CommandNotFound/CommandNotFound.py
+#, python-format
+msgid "The renamed-commands table %(path)s could not be read (%(error)s);
reinstalling command-not-found restores it."
+msgstr "無法讀取重新命名指令表 %(path)s(%(error)s);重新安裝 command-not-found 即可還原。"
diff --git a/setup.py b/setup.py
index 3ced20e..3a6361f 100755
--- a/setup.py
+++ b/setup.py
@@ -14,6 +14,7 @@ setup(
},
data_files=[
('share/command-not-found/', glob.glob("data/*.db")),
+ ('share/command-not-found/', ['data/renamed-commands']),
('../etc', ['bash_command_not_found', 'zsh_command_not_found']),
('../etc/apt/apt.conf.d', ['data/50command-not-found']),
])
Description: The commands Debian installs under another name
Made by make-renamed.py (Sparky Stereo OS build tree,
sparky-os/packages/command-not-found).
Author: Daniel Ramos (Capitain Jack) <[email protected]>
Forwarded: no
Last-Update: 2026-11-02
---
--- /dev/null
+++ b/data/renamed-commands
@@ -0,0 +1,25 @@
+# Commands that Debian installs under another name, because another package
has the name (Debian Policy 10.1).
+# command-not-found names the package of each row, with the name to run,
before the packages that have the
+# command under its own name ("first") or after them ("after"), as Debian's
popularity contest ranks them.
+# Made by make-renamed.py from Debian forky's Contents, Packages and
popularity contest and each source's own
+# README.Debian, NEWS or changelog (the file named per row).
+# Each row: the command, the package, the name it is installed as, first or
after; the line above it names the
+# Debian text.
+# antlr 2.7.7+dfsg-14, debian/changelog
+antlr antlr runantlr first
+# rust-bat 0.26.1+ds-1, debian/README.Debian
+bat bat batcat first
+# cal 4.1-1, debian/README.Debian
+cal ccal ccal after
+# iraf 2.18.2-4, debian/README.Debian
+cl iraf irafcl first
+# rust-fd-find 10.5.0-1, debian/README.Debian
+fd fd-find fdfind first
+# spline 1.2-6, debian/changelog
+spline spline aspline after
+# task-spooler 1.0.3+dfsg1-2, debian/changelog
+ts task-spooler tsp after
+# lam 7.1.4-8, debian/changelog
+wipe lam-runtime lamwipe after
+# xsd 4.0.0-10, debian/README.Debian
+xsd xsdcxx xsdcxx after
#!/usr/bin/python3
# Created by Paweł "pavroo" Pijanowski and Daniel Ramos (Capitain Jack)
2026/11/02
# Copyright 2026 under the GNU GPL3 License
# Writes renamed-commands, the table of commands Debian installs under another
name, from Debian's own data (ORDER 252b,
# study row J15). Debian Policy 10.1: "Two different packages must not install
programs with different functionality
# but with the same filenames ... If this case happens, one of the programs
must be renamed." So `fd` is fd-find's
# `fdfind` (fdclone has /usr/bin/fd) and `bat` is bat's `batcat`
(bacula-console-qt has /usr/sbin/bat).
# A row is written when both hold:
# 1. Contents: the package ships the installed name R (in bin, sbin, usr/bin,
usr/sbin or usr/games) but not the
# command C, and a package of another source ships C (the clash). Either C
is the package's or its source's name
# (without the rust-, golang-, python-, node-, ruby- or haskell- prefix),
one of its leading dash parts or the
# initials of its dash parts, and R is C with at most four letters added
before or after it; or R is C with one
# of those names of three letters or more, without a dash, joined before
or after it (num-utils' prefix "num").
# 2. Debian's own words: a paragraph of the source's debian/README.Debian,
debian/NEWS or debian/changelog (from
# sources.debian.org, at the version in Packages) says why (renamed,
clash, conflict, collides, already used), and
# one of its sentences names C and then R, with "to", "into", "as", "is"
or "it's" between them ("renamed from
# 'bat' to 'batcat'", "install fd into ... a symlink from this file to
/usr/bin/fdfind").
# Each row's fourth field orders the offer by Debian's popularity contest:
"first" when the package has more
# installations than every package that ships C (fd-find against fdclone), so
it is named before them; "after" when
# the program under the name is the more installed one (moreutils' ts against
task-spooler's tsp), so it is named
# after them. Every rename Debian's own words document is kept: none is left
out.
# Usage: make-renamed.py --data DIR --evidence DIR [--fetch] --out
renamed-commands --report report.tsv
# DIR holds Debian's indexes, decompressed: Contents-*.txt (lines under bin
and sbin), Packages-*, popcon-by_inst.gz.
# --evidence keeps the Debian texts read (one folder per source and version);
--fetch downloads the missing ones.
# Last update 2026/11/02
import argparse, collections, glob, gzip, os, re, sys, time, urllib.parse,
urllib.request
PREFIXES = ('rust-', 'golang-', 'python-', 'node-', 'ruby-', 'haskell-')
DIRS = ('bin/', 'sbin/', 'usr/bin/', 'usr/sbin/', 'usr/games/')
WHY = re.compile(r'renam|clash|conflict|collid|collision|already (used|taken|in
use|exists)', re.I)
TEXTS = ('debian/README.Debian', 'debian/NEWS', 'debian/changelog')
def read_packages(data):
info = {}
for path in sorted(glob.glob(os.path.join(data, 'Packages-*'))):
component = os.path.basename(path).split('-')[1]
component = 'non-free' if component == 'non' else component
stanza = {}
for line in open(path, encoding='utf-8', errors='replace'):
if line.strip() == '':
if 'Package' in stanza:
name = stanza['Package']
src, _, ver = stanza.get('Source', name).partition(' ')
ver = ver.strip('()') or stanza.get('Version', '')
info.setdefault(name, (src, ver, component))
stanza = {}
elif not line.startswith(' ') and ':' in line:
key, _, value = line.partition(':')
stanza[key] = value.strip()
return info
def read_contents(data):
commands, shipped = collections.defaultdict(set),
collections.defaultdict(set)
for path in sorted(glob.glob(os.path.join(data, 'Contents-*.txt'))):
for line in open(path, encoding='utf-8', errors='replace'):
try:
name, packages = line.rstrip('\n').rsplit(None, 1)
except ValueError:
continue
if not name.startswith(DIRS):
continue
command = os.path.basename(name)
for package in packages.split(','):
package = package.rsplit('/', 1)[-1]
commands[command].add(package)
shipped[package].add(command)
return commands, shipped
def read_popcon(data):
installs = {}
with gzip.open(os.path.join(data, 'popcon-by_inst.gz'), 'rt',
encoding='utf-8', errors='replace') as fp:
for line in fp:
fields = line.split()
if len(fields) > 2 and fields[0].isdigit():
installs[fields[1]] = int(fields[2])
return installs
def names_of(package, src):
names = set()
for name in {package, src}:
for prefix in PREFIXES:
if name.startswith(prefix):
name = name[len(prefix):]
parts = name.split('-')
names.update('-'.join(parts[:i]) for i in range(1, len(parts) + 1))
if len(parts) > 1:
names.add(''.join(part[0] for part in parts if part))
return names
def candidates(info, commands, shipped):
rows = []
for package, own in sorted(shipped.items()):
src = info.get(package, (package, '', 'main'))[0]
for command in sorted(names_of(package, src)):
if len(command) < 2 or command in own:
continue
clash = sorted(q for q in commands.get(command, ()) if info.get(q,
(q,))[0] != src)
if not clash:
continue
for real in sorted(own):
extra = real[len(command):] if real.startswith(command) else
real[:-len(command)] if real.endswith(command) else None
if extra and re.fullmatch(r'[a-z]{1,4}', extra):
rows.append((command, package, real, src, clash))
# R is C with a dash-free name of the package joined to it (num-utils:
average to numaverage)
for package, own in sorted(shipped.items()):
src = info.get(package, (package, '', 'main'))[0]
for part in sorted(n for n in names_of(package, src) if len(n) > 2 and
'-' not in n):
for real in sorted(own):
for command in (real[len(part):] if real.startswith(part) else
'', real[:-len(part)] if real.endswith(part) else ''):
if len(command) < 2 or command in own or not
re.fullmatch(r'[a-z][a-z0-9]*', command):
continue
clash = sorted(q for q in commands.get(command, ()) if
info.get(q, (q,))[0] != src)
if clash and (command, package, real, src, clash) not in
rows:
rows.append((command, package, real, src, clash))
return sorted(rows)
def evidence_path(evidence, src, ver, text):
return os.path.join(evidence, src, ver, text.replace('/', '_'))
def fetch(evidence, src, ver, component):
for text in TEXTS:
path = evidence_path(evidence, src, ver, text)
if os.path.exists(path) or os.path.exists(path + '.none'):
continue
os.makedirs(os.path.dirname(path), exist_ok=True)
initial = src[:4] if src.startswith('lib') else src[0]
url = 'https://sources.debian.org/data/%s/%s/%s/%s/%s' % (
component, initial, src, urllib.parse.quote(ver), text)
for attempt in range(4):
try:
with urllib.request.urlopen(url, timeout=60) as reply:
body = reply.read()
with open(path, 'wb') as fp:
fp.write(body)
break
except urllib.error.HTTPError as error:
if error.code != 404:
raise
open(path + '.none', 'w').close()
break
except (urllib.error.URLError, OSError):
# a dropped connection is tried again, then stops the run
if attempt == 3:
raise
time.sleep(5 * (attempt + 1))
time.sleep(0.5)
def debian_words(evidence, src, ver, command, real):
word = lambda w: r'(?<![\w.-])' + re.escape(w) + r'(?![\w-])'
# C, then within the sentence "to", "into", "as", "is" or "it's", then R
renamed = re.compile(word(command) +
r'(?:(?!\.\s).){0,120}?\b(?:to|into|as|is|it\'s)\b(?:(?!\.\s).){0,60}?' +
word(real), re.S)
for text in TEXTS:
path = evidence_path(evidence, src, ver, text)
if not os.path.exists(path):
continue
body = open(path, encoding='utf-8', errors='replace').read()
# README and NEWS paragraphs are separated by empty lines, changelog
items start with an asterisk
parts = re.split(r'\n\s*\n|\n(?=\s+[*-] )', body)
for part in parts:
if WHY.search(part) and renamed.search(' '.join(part.split())):
return text, ' '.join(part.split())
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--data', required=True)
parser.add_argument('--evidence', required=True)
parser.add_argument('--fetch', action='store_true')
parser.add_argument('--out', required=True)
parser.add_argument('--report', required=True)
args = parser.parse_args()
info = read_packages(args.data)
commands, shipped = read_contents(args.data)
installs = read_popcon(args.data)
table, report = [], []
for command, package, real, src, clash in candidates(info, commands,
shipped):
ver, component = info.get(package, (src, '', 'main'))[1:]
if args.fetch:
fetch(args.evidence, src, ver, component)
words = debian_words(args.evidence, src, ver, command, real)
mine, theirs = installs.get(package, 0), max(installs.get(q, 0) for q
in clash)
order = 'first' if mine > theirs else 'after'
if not words:
verdict = 'no Debian text'
else:
verdict = 'written, ' + order
table.append((command, package, real, order, src, ver, words[0]))
report.append((verdict, command, package, real, ','.join(clash),
'%d/%d' % (mine, theirs),
'%s %s %s' % (src, ver, words[0]) if words else '',
words[1][:300] if words else ''))
with open(args.report, 'w') as fp:
fp.write('verdict\tcommand\tpackage\tinstalled as\tpackages with the
command\tpopcon installs (package/most of the others)\tDebian text\tits
words\n')
for row in sorted(report):
fp.write('\t'.join(row) + '\n')
seen = collections.Counter(row[0] for row in table)
duplicates = [command for command, n in seen.items() if n > 1]
if duplicates:
sys.exit('make-renamed: more than one renamed package for %s; see %s' %
(', '.join(duplicates), args.report))
with open(args.out, 'w') as fp:
fp.write('# Commands that Debian installs under another name, because
another package has the name (Debian Policy 10.1).\n')
fp.write('# command-not-found names the package of each row, with the
name to run, before the packages that have the\n')
fp.write('# command under its own name ("first") or after them
("after"), as Debian\'s popularity contest ranks them.\n')
fp.write('# Made by make-renamed.py from Debian forky\'s Contents,
Packages and popularity contest and each source\'s own\n')
fp.write('# README.Debian, NEWS or changelog (the file named per
row).\n')
fp.write('# Each row: the command, the package, the name it is
installed as, first or after; the line above it names the\n')
fp.write('# Debian text.\n')
for command, package, real, order, src, ver, text in sorted(table):
fp.write('# %s %s, %s\n%s\t%s\t%s\t%s\n' % (src, ver, text,
command, package, real, order))
print('make-renamed: %d candidates, %d rows' % (len(report), len(table)))
main()