New Upstream Release - px

Ready changes

Summary

Merged new upstream version: 3.3.1 (was: 3.1.0).

Resulting package

Built on 2023-05-30T08:01 (took 5m11s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases px

Lintian Result

Diff

diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml
index b05a8a3..da8afec 100644
--- a/.github/workflows/linux-ci.yml
+++ b/.github/workflows/linux-ci.yml
@@ -29,13 +29,18 @@ jobs:
         uses: actions/checkout@v2
         with:
           fetch-depth: 0 # For getting tags from the repo
+      - name: Cache .tox directory
+        uses: actions/cache@v2
+        with:
+          path: .tox
+          key: ${{ runner.os }}-${{ hashFiles('tox.ini') }}
       - name: Create a virtualenv
         run: |
           python3 -m venv env
       - name: Install tox in our virtualenv
         run: |
           . ./env/bin/activate
-          pip install tox
+          pip install tox==4.0.9
       - name: Run tox in our virtualenv
         run: |
           . ./env/bin/activate
diff --git a/.github/workflows/macos-ci.yml b/.github/workflows/macos-ci.yml
index 8440d5b..c4c09a9 100644
--- a/.github/workflows/macos-ci.yml
+++ b/.github/workflows/macos-ci.yml
@@ -9,19 +9,24 @@ on:
 
 jobs:
   tox:
-    runs-on: macos-10.15
+    runs-on: macos-12
     steps:
       - name: Check out repository code
         uses: actions/checkout@v2
         with:
           fetch-depth: 0 # For getting tags from the repo
+      - name: Cache .tox directory
+        uses: actions/cache@v2
+        with:
+          path: .tox
+          key: ${{ runner.os }}-${{ hashFiles('tox.ini') }}
       - name: Create a virtualenv
         run: |
           python3 -m venv env
       - name: Install tox in our virtualenv
         run: |
           . ./env/bin/activate
-          pip install tox
+          pip install tox==4.0.9
       - name: Run tox in our virtualenv
         run: |
           . ./env/bin/activate
diff --git a/README.rst b/README.rst
index b47f9fa..498597a 100644
--- a/README.rst
+++ b/README.rst
@@ -5,30 +5,45 @@
 
 See below for `how to install`_.
 
+``ptop`` is what I usually use when `Bubblemon`_ shows something unexpected is
+going on.
+
+``px`` I use for figuring out things like "do I still have any `Flutter`_
+processes running in the background"?
+
 Output
 ======
 
 ``ptop``
 --------
+
+If you're coming from ``htop`` or some other ``top`` variant, here's what to
+expect from ``ptop``, with explanations below the screenshot:
+
 |ptop screenshot|
 
-* Note how the default sort order of CPU-usage-since-``ptop``-started makes the
-  display mostly stable.
 * Note the core count right next to the system load number, for easy comparison.
-* Note the load history graph next to the load numbers. On this system the
-  load went up during the last minute. This is a visualization of the numbers
+* Note the load history graph next to the load numbers. On this system the load
+  has been high for the last 15 minutes. This is a visualization of the numbers
   you get from ``uptime``.
-* Note the ``IO Load`` number, this shows which IO device had the highest
-  average throughput since ``ptop`` launched.
+* Note the bars showing which programs / users are using your memory below the
+  memory numbers
+* Note the ``IO Load`` number, showing which IO device had the highest average
+  throughput since ``ptop`` launched.
+* Note how the default sort order of CPUTIME-since-``ptop``-started makes the
+  display mostly stable and enables you to sort by CPU usage.
 * Note that binaries launched while ``ptop`` is running are listed at the bottom
   of the display.
-* Note the visualization of which programs / users are using your memory below
-  the memory numbers
+* Note how the Python program on the second to last line is shown as
+  ``run_adapter.py`` (the program) rather than ``python3`` (the runtime). `This
+  support is available for many VMs`_ like Java, Node, ...
 * Selecting a process with Enter will offer you to see detailed information
   about that process, in ``$PAGER``, `moar`_ or ``less``. Or to kill it.
 * After you press ``q`` to quit, the display is retained and some lines at the
   bottom are removed to prevent the information you want from scrolling out of
   view.
+* A help text on the bottom hints you how to search / filter (interactively),
+  change sort order or how to pick processes for further inspection or killing.
 
 ``px``
 -------------
@@ -176,12 +191,16 @@ Installation
 ------------
 On `Debian 10 Buster`_ or later, and on `Ubuntu 19.04 Disco`_ and later, install using::
 
-  sudo apt-get install px
+  sudo apt install px
 
 If you have `Homebrew`_ on your system (likely on macOS)::
 
   brew install px
 
+On `Arch Linux`_::
+
+  paru -S px_ptop
+
 On other systems, install into ``/usr/local/bin`` by copy / pasting this command
 into a terminal::
 
@@ -340,9 +359,12 @@ DONE
 * ptop: Let user switch between CPU time sort and memory sort
 
 .. _how to install: #installation
+.. _Bubblemon: https://walles.github.io/bubblemon/
+.. _Flutter: https://flutter.dev
 .. _Debian 10 Buster: https://wiki.debian.org/DebianBuster
 .. _Ubuntu 19.04 Disco: https://launchpad.net/ubuntu/disco/
 .. _Homebrew: https://brew.sh
+.. _Arch Linux: https://archlinux.org/
 .. _download the latest px.pex: https://github.com/walles/px/releases/latest
 .. _Unix domain sockets: https://en.wikipedia.org/wiki/Unix_domain_socket
 .. _This support is available for many VMs: https://github.com/walles/px/blob/python/tests/px_commandline_test.py
diff --git a/debian/changelog b/debian/changelog
index ac566af..5ebb081 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,8 +1,9 @@
-px (3.1.0-2) UNRELEASED; urgency=medium
+px (3.3.1-1) UNRELEASED; urgency=medium
 
   * Set upstream metadata fields: Bug-Database, Bug-Submit, Repository-Browse.
+  * New upstream release.
 
- -- Debian Janitor <janitor@jelmer.uk>  Sun, 20 Nov 2022 01:00:08 -0000
+ -- Debian Janitor <janitor@jelmer.uk>  Tue, 30 May 2023 07:56:50 -0000
 
 px (3.1.0-1) unstable; urgency=medium
 
diff --git a/debian/patches/000-avoid-version-gen.patch b/debian/patches/000-avoid-version-gen.patch
index 6c541a8..81ce435 100644
--- a/debian/patches/000-avoid-version-gen.patch
+++ b/debian/patches/000-avoid-version-gen.patch
@@ -5,9 +5,11 @@ Author: Josue Ortega <josue@debian.org>
 Last-Update: 2022-07-17
 Forwarded: not-needed
 
---- a/setup.py
-+++ b/setup.py
-@@ -9,33 +9,6 @@
+Index: px.git/setup.py
+===================================================================
+--- px.git.orig/setup.py
++++ px.git/setup.py
+@@ -9,33 +9,6 @@ import subprocess
  
  from setuptools import setup
  
@@ -41,7 +43,7 @@ Forwarded: not-needed
  with open("requirements.txt", encoding="utf-8") as reqsfile:
      requirements = reqsfile.readlines()
  
-@@ -44,13 +17,9 @@
+@@ -44,13 +17,9 @@ with open(
  ) as fp:
      LONG_DESCRIPTION = fp.read()
  
@@ -56,9 +58,11 @@ Forwarded: not-needed
      description="ps and top for Human Beings",
      long_description=LONG_DESCRIPTION,
      author="Johan Walles",
---- a/px/px.py
-+++ b/px/px.py
-@@ -53,6 +53,7 @@
+Index: px.git/px/px.py
+===================================================================
+--- px.git.orig/px/px.py
++++ px.git/px/px.py
+@@ -53,6 +53,7 @@ from . import px_processinfo
  
  from typing import Optional, List
  
@@ -66,7 +70,7 @@ Forwarded: not-needed
  
  ERROR_REPORTING_HEADER = """
  ---
-@@ -133,9 +134,8 @@
+@@ -133,9 +134,8 @@ def handleLogMessages(messages: Optional
      # even if we don't use it. And this will make test avoidance fail to avoid
      # px.py tests every time you make a new commit (because committing recreates
      # version.py).
@@ -77,7 +81,7 @@ Forwarded: not-needed
  
      sys.stderr.write("\n")
      sys.stderr.write("Python version: " + sys.version + "\n")
-@@ -163,9 +163,7 @@
+@@ -163,9 +163,7 @@ def _main(argv: List[str]) -> None:
          # NOTE: If we "import version" at the top of this file, we will depend on it even if
          # we don't use it. And this will make test avoidance fail to avoid px.py tests every
          # time you make a new commit (because committing recreates version.py).
diff --git a/debian/patches/remove-install-requires.patch b/debian/patches/remove-install-requires.patch
index 738723e..0019f7f 100644
--- a/debian/patches/remove-install-requires.patch
+++ b/debian/patches/remove-install-requires.patch
@@ -4,9 +4,11 @@ Author: Josue Ortega <josue@debian.org>
 Last-Update: 2021-11-01
 Forwarded: not-needed
 
---- a/setup.py
-+++ b/setup.py
-@@ -43,7 +43,7 @@
+Index: px.git/setup.py
+===================================================================
+--- px.git.orig/setup.py
++++ px.git/setup.py
+@@ -39,7 +39,7 @@ setup(
          "Topic :: Utilities",
      ],
      packages=["px"],
diff --git a/devbin/release.sh b/devbin/release.sh
index 7a665a1..b6ad8ee 100755
--- a/devbin/release.sh
+++ b/devbin/release.sh
@@ -111,7 +111,7 @@ virtualenv "${ENVDIR}"
 # https://github.com/pypa/twine/issues/273#issuecomment-334911815
 pip install "ndg-httpsclient == 0.4.3"
 
-pip install "twine == 1.9.1"
+pip install "twine == 4.0.2"
 
 # Upload!
 echo
diff --git a/install.sh b/install.sh
index 4630527..8d5230a 100644
--- a/install.sh
+++ b/install.sh
@@ -11,32 +11,33 @@ PXPREFIX=${PXPREFIX:-/usr/local/bin}
 
 # Get the download URL for the latest release
 TEMPFILE=$(mktemp || mktemp -t px-install-releasesjson.XXXXXXXX)
-curl -s https://api.github.com/repos/$REPO/releases > "${TEMPFILE}"
-if grep "API rate limit exceeded" "${TEMPFILE}" > /dev/null ; then
+curl -s https://api.github.com/repos/${REPO}/releases >"${TEMPFILE}"
+if grep "API rate limit exceeded" "${TEMPFILE}" >/dev/null; then
   cat "${TEMPFILE}" >&2
   exit 1
 fi
 
 URL=$(
-  grep browser_download_url "$TEMPFILE" \
-  | cut -d '"' -f 4 \
-  | head -n 1)
+  grep browser_download_url "${TEMPFILE}" |
+    cut -d '"' -f 4 |
+    head -n 1
+)
 rm "${TEMPFILE}"
 
 echo "Downloading the latest release..."
-echo "  $URL"
+echo "  ${URL}"
 TEMPFILE=$(mktemp || mktemp -t px-install.XXXXXXXX)
-curl -L -s "$URL" > "$TEMPFILE"
-chmod a+x "$TEMPFILE"
+curl -L -s "${URL}" >"${TEMPFILE}"
+chmod a+x "${TEMPFILE}"
 
 echo "Installing the latest release..."
 echo
-echo "sudo install px.pex /usr/local/bin/px"
-sudo install "$TEMPFILE" "${PXPREFIX}/px"
-echo "sudo install px.pex /usr/local/bin/ptop"
-sudo install "$TEMPFILE" "${PXPREFIX}/ptop"
+echo "sudo install px.pex ${PXPREFIX}/px"
+sudo install "${TEMPFILE}" "${PXPREFIX}/px"
+echo "sudo ln px ${PXPREFIX}/ptop"
+sudo ln -sf px "${PXPREFIX}/ptop"
 
-rm -f "$TEMPFILE"
+rm -f "${TEMPFILE}"
 
 echo
 echo "Installation done, now run one or both of:"
diff --git a/mypy.ini b/mypy.ini
index 0f6badb..12bdfc7 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1,7 +1,6 @@
 [mypy]
 check_untyped_defs = True
 
-disallow_any_explicit = True
 disallow_any_generics = True
 disallow_any_unimported = True
 disallow_subclassing_any = True
@@ -13,9 +12,3 @@ warn_incomplete_stub = True
 warn_redundant_casts = True
 warn_return_any = True
 warn_unused_ignores = True
-
-[mypy-setuptools]
-ignore_missing_imports = True
-
-[mypy-pytest]
-ignore_missing_imports = True
diff --git a/px/px.py b/px/px.py
index ee20afa..dbfef46 100644
--- a/px/px.py
+++ b/px/px.py
@@ -251,6 +251,10 @@ def _main(argv: List[str]) -> None:
     if sort_cpupercent:
         procs = list(filter(lambda p: p.cpu_percent is not None, procs))
         procs = sorted(procs, key=operator.attrgetter("cpu_percent"))
+    if search:
+        # Put exact search matches last. Useful for "px cat" or other short
+        # search strings with tons of hits.
+        procs = sorted(procs, key=lambda p: p.command == search)
     lines = px_terminal.to_screen_lines(procs, None, None, with_username)
 
     if columns:
diff --git a/px/px_commandline.py b/px/px_commandline.py
index c01a082..e658129 100644
--- a/px/px_commandline.py
+++ b/px/px_commandline.py
@@ -2,9 +2,11 @@
 
 import re
 import os.path
+import logging
 
-from typing import List
-from typing import Optional
+from typing import List, Optional
+
+LOG = logging.getLogger(__name__)
 
 
 # Match "[kworker/0:0H]", no grouping
@@ -76,6 +78,20 @@ def try_clarify_electron(commandline: str) -> Optional[str]:
     return None
 
 
+def faillog(commandline: str, parse_result: Optional[str]) -> str:
+    """
+    If successful, just return the result. If unsuccessful log the problem and
+    return the VM name.
+    """
+    if parse_result:
+        return parse_result
+
+    LOG.debug("Parsing failed, using fallback: <%s>", commandline)
+
+    vm = os.path.basename(to_array(commandline)[0])
+    return vm
+
+
 def get_command(commandline: str) -> str:
     """
     Extracts the command from the command line.
@@ -95,7 +111,7 @@ def get_command(commandline: str) -> str:
     command = os.path.basename(to_array(commandline)[0])
 
     if command.startswith("python") or command == "Python":
-        return get_python_command(commandline)
+        return faillog(commandline, get_python_command(commandline))
 
     if command == "Electron":
         clarified = try_clarify_electron(commandline)
@@ -103,48 +119,82 @@ def get_command(commandline: str) -> str:
             return clarified
 
     if command == "java":
-        return get_java_command(commandline)
+        return faillog(commandline, get_java_command(commandline))
 
     if command == "ruby":
         # Switches list inspired by ruby 2.3.7p456 --help output
-        return get_generic_script_command(
+        return faillog(
             commandline,
-            [
-                "-a",
-                "-d",
-                "--debug",
-                "--disable",
-                #
-                # Quickfix for #74, better implementations welcome!
-                # https://github.com/walles/px/issues/74
-                "-Eascii-8bit:ascii-8bit",
-                #
-                "-l",
-                "-n",
-                "-p",
-                "-s",
-                "-S",
-                "-v",
-                "--verbose",
-                "-w",
-                "-W0",
-                "-W1",
-                "-W2",
-                "--",
-            ],
+            get_generic_script_command(
+                commandline,
+                ignore_switches=[
+                    "-a",
+                    "-d",
+                    "--debug",
+                    "--disable",
+                    #
+                    # Quickfix for #74, better implementations welcome!
+                    # https://github.com/walles/px/issues/74
+                    "-Eascii-8bit:ascii-8bit",
+                    #
+                    "-l",
+                    "-n",
+                    "-p",
+                    "-s",
+                    "-S",
+                    "-v",
+                    "--verbose",
+                    "-w",
+                    "-W0",
+                    "-W1",
+                    "-W2",
+                    "--",
+                ],
+            ),
         )
 
     if command == "sudo":
-        return get_sudo_command(commandline)
+        return faillog(commandline, get_sudo_command(commandline))
+
+    if command in [
+        # NOTE: This list contains binaries that are mostly used with
+        # subcommands. Scripts (like brew.rb) are handled in
+        # get_generic_script_command().
+        #
+        # "gradle" and "mvn" are not handled at all, could be added to
+        # get_java_command().
+        "apt-get",
+        "apt",
+        "cargo",
+        "docker",
+        "docker-compose",
+        "git",
+        "go",
+        "npm",
+        "pip",
+        "pip3",
+        "rustup",
+    ]:
+        return faillog(commandline, get_with_subcommand(commandline))
+
+    if command == "terraform":
+        return faillog(
+            commandline, get_with_subcommand(commandline, ignore_switches=["-chdir"])
+        )
 
     if command == "node":
-        return get_generic_script_command(commandline, ["--max_old_space_size"])
+        return faillog(
+            commandline,
+            get_generic_script_command(
+                commandline, ignore_switches=["--max_old_space_size"]
+            ),
+        )
 
     if command in ["bash", "sh"]:
-        return get_generic_script_command(commandline)
+        return faillog(commandline, get_generic_script_command(commandline))
 
     if PERL_BIN.match(command):
-        return get_generic_script_command(commandline)
+        return faillog(commandline, get_generic_script_command(commandline))
 
     app_name_prefix = get_app_name_prefix(commandline)
     if is_human_friendly(command):
@@ -156,19 +206,24 @@ def get_command(commandline: str) -> str:
 
     command_split = command.split(".")
     if len(command_split) > 1:
+        command_suggestion = ""
         if len(command_split[-1]) > 4:
             # Pretend all the dots are a kind of path and go for the last
             # part only
-            command = command_split[-1]
+            command_suggestion = command_split[-1]
         else:
             # Assume last part is a file suffix (like ".exe") and take the
             # next to last part
-            command = command_split[-2]
+            command_suggestion = command_split[-2]
+        if len(command_suggestion) >= 5:
+            # Good enough!
+            command = command_suggestion
 
     return app_name_prefix + command
 
 
-def get_python_command(commandline: str) -> str:
+def get_python_command(commandline: str) -> Optional[str]:
+    """Returns None if we failed to figure out the script name"""
     array = to_array(commandline)
     array = list(filter(lambda s: s, array))
 
@@ -207,17 +262,18 @@ def get_python_command(commandline: str) -> str:
         if array[1] == "-m" and not array[2].startswith("-"):
             return os.path.basename(array[2])
 
-    return python
+    return None
 
 
-def get_sudo_command(commandline: str) -> str:
+def get_sudo_command(commandline: str) -> Optional[str]:
+    """Returns None if we failed to figure out the script name"""
     without_sudo = commandline[5:].strip()
     if not without_sudo:
         return "sudo"
 
     if without_sudo.startswith("-"):
         # Give up on options
-        return "sudo"
+        return None
 
     return "sudo " + get_command(without_sudo)
 
@@ -234,7 +290,8 @@ def prettify_fully_qualified_java_class(class_name: str) -> str:
     return split[-1]
 
 
-def get_java_command(commandline: str) -> str:
+def get_java_command(commandline: str) -> Optional[str]:
+    """Returns None if we failed to figure out the script name"""
     array = to_array(commandline)
     java = os.path.basename(array[0])
     if len(array) == 1:
@@ -248,16 +305,14 @@ def get_java_command(commandline: str) -> str:
 
         if state == "skip next":
             if component.startswith("-"):
-                # Skipping switches doesn't make sense. We're lost, fall back to
-                # just returning the command name
-                return java
+                # Skipping switches doesn't make sense. We're lost.
+                return None
             state = "scanning"
             continue
         if state == "return next":
             if component.startswith("-"):
-                # Returning switches doesn't make sense. We're lost, fall back
-                # to just returning the command name
-                return java
+                # Returning switches doesn't make sense. We're lost.
+                return None
             return os.path.basename(component)
         if state == "scanning":
             if component.startswith("-X"):
@@ -309,18 +364,40 @@ def get_java_command(commandline: str) -> str:
                 continue
             if component.startswith("-"):
                 # Unsupported switch, give up
-                return java
+                return None
             return prettify_fully_qualified_java_class(component)
 
         raise ValueError(f"Unhandled state <{state}> at <{component}> for: {array}")
 
     # We got to the end without being able to come up with a better name, give up
-    return java
+    return None
+
+
+def get_with_subcommand(
+    commandline: str, ignore_switches: Optional[List[str]] = None
+) -> Optional[str]:
+    array = to_array(commandline)
+
+    if ignore_switches is None:
+        ignore_switches = []
+    while len(array) > 1 and array[1].split("=")[0] in ignore_switches:
+        del array[1]
+
+    command = os.path.basename(array[0])
+    if len(array) == 1:
+        return command
+
+    if array[1].startswith("-"):
+        # Unknown option, help!
+        return command
+
+    return f"{command} {array[1]}"
 
 
 def get_generic_script_command(
     commandline: str, ignore_switches: Optional[List[str]] = None
-) -> str:
+) -> Optional[str]:
+    """Returns None if we failed to figure out the script name"""
     array = to_array(commandline)
 
     if ignore_switches is None:
@@ -333,7 +410,20 @@ def get_generic_script_command(
         return vm
 
     if array[1].startswith("-"):
-        # This is some option, we don't do options
-        return vm
-
-    return os.path.basename(array[1])
+        # Unknown option, help!
+        return None
+
+    script = os.path.basename(array[1])
+    if len(array) == 2:
+        # vm + script
+        return script
+    if script not in ["brew.rb", "yarn.js"]:
+        return script
+    script = os.path.splitext(script)[0]
+
+    subcommand = array[2]
+    if subcommand.startswith("-"):
+        # Unknown option before the subcommand
+        return script
+
+    return f"{script} {subcommand}"
diff --git a/px/px_load.py b/px/px_load.py
index 383f2a4..a8773ec 100644
--- a/px/px_load.py
+++ b/px/px_load.py
@@ -9,7 +9,7 @@ import os
 from . import px_cpuinfo
 from . import px_terminal
 
-from typing import Tuple
+from typing import Tuple, Optional
 
 
 physical, logical = px_cpuinfo.get_core_count()
@@ -84,7 +84,7 @@ def get_load_values() -> Tuple[float, float, float]:
     return (avg0to1, avg1to5, avg5to15)
 
 
-def get_load_string(load_values: Tuple[float, float, float] = None) -> str:
+def get_load_string(load_values: Optional[Tuple[float, float, float]] = None) -> str:
     """
     Example return string, underlines indicate bold:
     "1.5  [4 cores | 8 virtual]  [15m history: GRAPH]"
diff --git a/px/px_pager.py b/px/px_pager.py
index 537377c..a4466f0 100644
--- a/px/px_pager.py
+++ b/px/px_pager.py
@@ -126,7 +126,12 @@ def page_process_info(
     info_thread = threading.Thread(
         target=_pump_info_to_fd, args=(pager_stdin, process, processes)
     )
-    info_thread.setDaemon(True)  # Terminating ptop while this is running is fine
+
+    # Terminating ptop while this is running is fine. This is deprecated since
+    # Python 3.10, but we want to support older Pythons as well so let's keep it
+    # this way for now.
+    info_thread.setDaemon(True)  # pylint: disable=deprecated-method
+
     info_thread.start()
 
     pagerExitcode = pager.wait()
diff --git a/px/px_process.py b/px/px_process.py
index f721564..69f5c4e 100644
--- a/px/px_process.py
+++ b/px/px_process.py
@@ -214,6 +214,9 @@ class PxProcess:
         if string in self.cmdline.lower():
             return True
 
+        if str(self.pid).startswith(string):
+            return True
+
         return False
 
     def get_command_line_array(self):
diff --git a/px/px_terminal.py b/px/px_terminal.py
index fb31b1c..91fa0ee 100644
--- a/px/px_terminal.py
+++ b/px/px_terminal.py
@@ -119,7 +119,7 @@ def read_select(
 
 
 def getch(
-    timeout_seconds: Optional[int] = None, fd: int = None
+    timeout_seconds: Optional[int] = None, fd: Optional[int] = None
 ) -> Optional[ConsumableString]:
     """
     Wait at most timeout_seconds for a character to become available on stdin.
@@ -219,7 +219,6 @@ def filter_out_unchanged_screen_lines(
 
 
 def draw_screen_lines(lines: List[str], columns: int) -> None:
-
     unfiltered_screen_lines = raw_lines_to_screen_lines(lines, columns)
     screen_lines = filter_out_unchanged_screen_lines(unfiltered_screen_lines, columns)
 
diff --git a/px/px_top.py b/px/px_top.py
index d583d1d..2329466 100644
--- a/px/px_top.py
+++ b/px/px_top.py
@@ -290,6 +290,19 @@ def get_screen_lines(
             filter(lambda p: p.match(search, require_exact_user=False), toplist)
         )
 
+        # Put exact search matches first. Useful for "px cat" or other short
+        # search strings with tons of hits.
+        search_pid = -1
+        try:
+            search_pid = int(search)
+        except ValueError:
+            pass
+        toplist = sorted(
+            toplist,
+            key=lambda p: search in (p.command, p.username) or p.pid == search_pid,
+            reverse=True,
+        )
+
     # Hand out different amount of lines to the different sections
     footer_height = 0
     cputop_minheight = 10
diff --git a/tests/px_commandline_test.py b/tests/px_commandline_test.py
index f68d832..d0024be 100644
--- a/tests/px_commandline_test.py
+++ b/tests/px_commandline_test.py
@@ -276,7 +276,7 @@ def test_get_command_ruby_switches():
         px_commandline.get_command(
             "/usr/bin/ruby -W0 /usr/local/bin/brew.rb install rust"
         )
-        == "brew.rb"
+        == "brew install"
     )
 
     # https://github.com/walles/px/issues/87
@@ -394,10 +394,43 @@ def test_get_homebrew_commandline():
                 ]
             )
         )
-        == "brew.rb"
+        == "brew upgrade"
     )
 
 
+def test_get_terraform_provider_commandline():
+    # Source: https://github.com/walles/px/issues/105
+    assert (
+        px_commandline.get_command(
+            ".terraform/providers/registry.terraform.io/heroku/heroku/4.8.0/darwin_amd64/terraform-provider-heroku_v4.8.0"
+        )
+        == "terraform-provider-heroku_v4.8.0"
+    )
+
+
+def test_get_terraform_commandline():
+    # Source: https://github.com/walles/px/issues/113
+    assert (
+        px_commandline.get_command("terraform -chdir=dev apply -target=abc123")
+        == "terraform apply"
+    )
+
+
+def test_get_go_commandline():
+    assert px_commandline.get_command("go build ./...") == "go build"
+    assert px_commandline.get_command("go --version") == "go"
+    assert px_commandline.get_command("/usr/local/bin/go") == "go"
+
+
+def test_get_git_commandline():
+    assert (
+        px_commandline.get_command("git clone git@github.com:walles/riff")
+        == "git clone"
+    )
+    assert px_commandline.get_command("git --version") == "git"
+    assert px_commandline.get_command("/usr/local/bin/git") == "git"
+
+
 def test_node_max_old_space():
     assert (
         px_commandline.get_command("node --max_old_space_size=4096 scripts/start.js")
diff --git a/tests/px_process_test.py b/tests/px_process_test.py
index 26b03bf..cd4ed0c 100644
--- a/tests/px_process_test.py
+++ b/tests/px_process_test.py
@@ -278,6 +278,14 @@ def test_match():
     assert p.match("air")
     assert p.match("play")
 
+    # Match PID by prefix but not substring. Exact matches are used for
+    # searching in ptop. Prefix matching is used to not throw the right answer
+    # away while the user is typing their search in ptop. Substring matching has
+    # no value.
+    assert p.match("47536")
+    assert p.match("4753")
+    assert not p.match("7536")
+
 
 def test_seconds_to_str():
     assert px_process.seconds_to_str(0.54321) == "0.54s"
diff --git a/tests/testutils.py b/tests/testutils.py
index 15ba6ee..9744c18 100644
--- a/tests/testutils.py
+++ b/tests/testutils.py
@@ -75,9 +75,9 @@ def create_file(
     name: str,
     device: Optional[str],
     pid: int,
-    access: str = None,
-    inode: str = None,
-    fd: int = None,
+    access: Optional[str] = None,
+    inode: Optional[str] = None,
+    fd: Optional[int] = None,
     fdtype: Optional[str] = None,
 ):
     # type (...) -> px_file.PxFile
diff --git a/tox.ini b/tox.ini
index 38d477e..c7f52ce 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,14 +5,14 @@
 # https://github.com/tox-dev/tox/pull/1202
 minversion = 3.8.0
 
-mypy_version = 0.790
+mypy_version = 1.2.0
 pylint_version = 2.13.9
-pytest_version = 5.4.1
+pytest_version = 7.1.3
 
 envlist=
     version.py
     black
-    mypy3
+    mypy
     pylint
     shellcheck
     installtest
@@ -23,6 +23,7 @@ envlist=
 [testenv]
 skip_install = true
 basepython = python3
+allowlist_externals = /bin/bash
 
 [testenv:version.py]
 commands =
@@ -39,15 +40,18 @@ deps =
 # Ubuntu version named in tox runs-on in linux-ci.yml.
 commands = /bin/bash -c 'if [ "{env:CI:}" ] ; then export CHECK="--check --diff --color" ; fi ;  black --target-version=py38 $CHECK ./*.py ./*/*.py'
 
-[testenv:mypy3]
-# NOTE: In theory mypy3 should probably depend on Black to get line numbers in
-# any error messages right. But since mypy3 tends to finish last, and being a
-# bit off isn't the end of the world, let's not depend on Black for now and hope
+[testenv:mypy]
+# NOTE: In theory mypy should probably depend on Black to get line numbers in
+# any error messages right. But since mypy tends to finish last, and being a bit
+# off isn't the end of the world, let's not depend on Black for now and hope
 # nobody notices.
 depends = version.py
 
 deps =
     mypy=={[tox]mypy_version}
+    pytest=={[tox]pytest_version}
+    types-setuptools==67.7.0.1  # Matches what was on Johan's laptop 2023-05-08
+    types-python-dateutil==2.8.19
 commands =
     /bin/bash -c 'mypy --pretty ./*.py ./*/*.py'
 
@@ -66,6 +70,7 @@ commands =
     /bin/bash -c 'shellcheck ./*.sh ./*/*.sh'
 
 [testenv:installtest]
+allowlist_externals = {toxinidir}/tests/installtest.sh
 commands =
     {toxinidir}/tests/installtest.sh
 
@@ -92,7 +97,7 @@ commands =
 depends = package
 commands =
     # Verify we have the correct shebang
-    /bin/bash -c 'head -n1 {toxinidir}/px.pex | grep -Eq "^#!/usr/bin/env python3$"'
+    /bin/bash -c 'head -n1 {toxinidir}/px.pex | grep -Eq "^\#!/usr/bin/env python3$"'
     # Test that there are no natively compiled dependencies. They make
     # distribution a lot harder. If this triggers, fix your dependencies!
     /bin/bash -c '! unzip -qq -l "{toxinidir}/px.pex" "*.so"'
@@ -104,7 +109,9 @@ commands =
 [testenv:test-wheel]
 # Test installing using pip
 depends = version.py black
-allowlist_externals = /bin/rm
+allowlist_externals =
+    /bin/bash
+    /bin/rm
 deps =
     setuptools == 44.1.1
     wheel == 0.35.1

More details

Full run details