This commit is contained in:
Xuehai Pan 2026-05-06 17:36:00 +08:00 committed by GitHub
commit d22f9db20a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3183 additions and 56 deletions

View file

@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed
-
- Remove third-party dependency `windows-curses` for Windows support by [@XuehaiPan](https://github.com/XuehaiPan) in [#149](https://github.com/XuehaiPan/nvitop/pull/149).
------

View file

@ -141,7 +141,7 @@ bash install-nvidia-driver.sh --latest --open # install the latest
Run `bash install-nvidia-driver.sh --help` for more information.
<a name="curses">*</a> The `curses` library is a built-in module of Python on Unix-like systems, and it is supported by a third-party package called `windows-curses` on Windows using PDCurses. Inconsistent behavior of `nvitop` may occur on different terminal emulators on Windows, such as missing mouse support.
<a name="curses">*</a> The `curses` library is a built-in module of Python on Unix-like systems, and `nvitop` supports Windows using ANSI escape codes. Inconsistent behavior of `nvitop` may occur on different terminal emulators on Windows, such as missing mouse support.
------

View file

@ -1,18 +1,30 @@
accessdenied
acs
addch
addnstr
addstr
api
args
ascii
attr
bel
bg
bool
boolean
bstate
cbreak
chgat
chtype
cli
cmdline
codepoint
colorama
colorscheme
compat
conf
const
cpython
csi
csv
ctrl
ctx
@ -27,6 +39,7 @@ divmod
docstring
doctest
ecc
endwin
enum
env
environ
@ -34,6 +47,7 @@ esc
failsafe
fallbacks
fg
fileno
fmt
func
getch
@ -49,6 +63,8 @@ gpuprocesssnapshot
gpus
gpustatslogger
hostname
ignorable
initscr
ints
ipython
isinstance
@ -57,6 +73,7 @@ keras
kib
kmd
kwargs
leaveok
len
libcuda
libcudart
@ -71,6 +88,7 @@ mig
migdevice
milliwatts
mps
msvcrt
mypy
namespace
nan
@ -78,6 +96,7 @@ noheader
noqa
nosuchprocess
nounits
num
nvidia
nvidia-smi
nvisel
@ -86,6 +105,7 @@ nvml
nvmlerror
oneshot
ord
ored
os
ot
pan
@ -103,16 +123,28 @@ pytorch
redhat
reentrant
resourcemetriccollector
rgb
rlist
rss
rstrip
rtx
runtime
rw
rx
rxvt
sanitization
scrollok
selectable
setupterm
sgi's
sgr
shader
sm
smi
stderr
stdin
stdout
stdscr
str
struct
subclasses
@ -123,6 +155,7 @@ superset
sys
tcc
tensorflow
termios
throughputinfo
toml
traceback
@ -134,10 +167,13 @@ uid
uids
unallocated
uncase
ungetch
unicode
unicodedata
uptime
utils
uuid
wcwidth
wddm
wdm
widestring

View file

@ -39,6 +39,7 @@ from nvitop.version import __version__
__all__ = [*api.__all__, 'select_devices']
# Add submodules to the top-level namespace
submodule = api
for submodule in (
caching,
collector,
@ -57,4 +58,4 @@ for submodule in (
# Required for `python -m nvitop.select` to work properly
sys.modules.pop(f'{__name__}.select', None)
del sys
del sys, submodule

View file

@ -104,13 +104,12 @@ if TYPE_CHECKING:
__all__ = ['colored', 'cprint']
if os.name == 'nt': # Windows
try:
from colorama import init
except ImportError:
pass
else:
init()
try:
from colorama import just_fix_windows_console
except ImportError:
pass
else:
just_fix_windows_console()
ATTRIBUTES: dict[Attribute, int] = {
@ -122,7 +121,6 @@ ATTRIBUTES: dict[Attribute, int] = {
'concealed': 8,
'strike': 9,
}
HIGHLIGHTS: dict[Highlight, int] = {
'on_black': 40,
'on_grey': 40, # Actually black but kept for backwards compatibility
@ -142,7 +140,6 @@ HIGHLIGHTS: dict[Highlight, int] = {
'on_light_cyan': 106,
'on_white': 107,
}
COLORS: dict[Color, int] = {
'black': 30,
'grey': 30, # Actually black but kept for backwards compatibility
@ -162,8 +159,6 @@ COLORS: dict[Color, int] = {
'light_cyan': 96,
'white': 97,
}
RESET = '\033[0m'
@ -239,19 +234,15 @@ def colored(
if not _can_do_color(no_color=no_color, force_color=force_color):
return result
fmt_str = '\033[%dm%s'
sequence = []
if color is not None:
result = fmt_str % (COLORS[color], result)
sequence.append(COLORS[color])
if on_color is not None:
result = fmt_str % (HIGHLIGHTS[on_color], result)
sequence.append(HIGHLIGHTS[on_color])
if attrs is not None:
for attr in attrs:
result = fmt_str % (ATTRIBUTES[attr], result)
result += RESET
sequence.extend(ATTRIBUTES[attr] for attr in attrs)
if sequence:
return f'\033[{";".join(map(str, sequence))}m{result}{RESET}'
return result

View file

@ -4,13 +4,12 @@
"""The interactive NVIDIA-GPU process viewer."""
import argparse
import curses
import os
import sys
import textwrap
from nvitop.api import HostProcess, libnvml
from nvitop.tui import TUI, USERNAME, Device, colored, libcurses, set_color, setlocale_utf8
from nvitop.tui import TUI, USERNAME, Device, colored, curses, libcurses, set_color, setlocale_utf8
from nvitop.version import __version__

View file

@ -8,6 +8,7 @@ from nvitop.tui.library import (
USERNAME,
Device,
colored,
curses,
libcurses,
set_color,
setlocale_utf8,
@ -21,6 +22,7 @@ __all__ = [
'USERNAME',
'Device',
'colored',
'curses',
'libcurses',
'set_color',
'setlocale_utf8',

View file

@ -3,7 +3,7 @@
# pylint: disable=missing-module-docstring
from nvitop.tui.library import host
from nvitop.tui.library import curses, host
from nvitop.tui.library.device import Device, MigDevice
from nvitop.tui.library.displayable import Displayable, DisplayableContainer
from nvitop.tui.library.history import BufferedHistoryGraph, HistoryGraph
@ -80,6 +80,7 @@ __all__ = [
'WideString',
'bytes2human',
'colored',
'curses',
'cut_string',
'host',
'libcurses',

View file

@ -0,0 +1,140 @@
# This file is part of nvitop, the interactive NVIDIA-GPU process viewer.
# License: GNU GPL version 3.
# pylint: disable=missing-module-docstring,missing-function-docstring,import-outside-toplevel,invalid-name
from __future__ import annotations
from typing import TYPE_CHECKING as _TYPE_CHECKING
HAS_CURSES_MODULE: bool = True
try:
import curses
except ImportError:
HAS_CURSES_MODULE = False
else:
del curses
from curses import * # noqa: F403 # pylint: disable=redefined-builtin
from curses import ascii # pylint: disable=redefined-builtin
if not HAS_CURSES_MODULE:
# pylint: disable-next=redefined-builtin
from nvitop.tui.library.curses import ascii # type: ignore[no-redef]
from nvitop.tui.library.curses._curses import * # type: ignore[assignment] # noqa: F403
if _TYPE_CHECKING:
from collections.abc import Callable as _Callable
from typing import TypeVar as _TypeVar # pylint: disable=ungrouped-imports
from typing_extensions import Concatenate as _Concatenate # Python 3.10+
from typing_extensions import ParamSpec as _ParamSpec # Python 3.10+
# pylint: disable-next=ungrouped-imports
from nvitop.tui.library.curses._curses import ( # type: ignore[assignment]
CursesWindow as window, # noqa: N813
)
_P = _ParamSpec('_P')
_T = _TypeVar('_T')
# Copied from the CPython repository.
# https://github.com/python/cpython/blob/HEAD/Lib/curses/__init__.py
# Some constants, most notably the ACS_* ones, are only added to the C
# _curses module's dictionary after initscr() is called. (Some
# versions of SGI's curses don't define values for those constants
# until initscr() has been called.) This wrapper function calls the
# underlying C initscr(), and then copies the constants from the
# _curses module to the curses package's dictionary. Don't do 'from
# curses import *' if you'll be needing the ACS_* constants.
def initscr() -> window: # pylint: disable=function-redefined
import os
import sys
from nvitop.tui.library.curses import _curses
assert sys.__stdout__ is not None
# we call setupterm() here because it raises an error
# instead of calling exit() in error cases.
_curses.setupterm(term=os.getenv('TERM', 'unknown'), fd=sys.__stdout__.fileno())
stdscr = _curses.initscr()
globals().update(
{
key: value
for key, value in vars(_curses).items()
if key.startswith('ACS_') or key in ('LINES', 'COLS')
},
)
return stdscr # type: ignore[return-value]
# This is a similar wrapper for start_color(), which adds the COLORS and
# COLOR_PAIRS variables which are only available after start_color() is
# called.
def start_color() -> None: # pylint: disable=function-redefined
from nvitop.tui.library.curses import _curses
retval = _curses.start_color() # type: ignore[func-returns-value] # pylint: disable=assignment-from-no-return
if hasattr(_curses, 'COLORS'):
globals()['COLORS'] = _curses.COLORS
if hasattr(_curses, 'COLOR_PAIRS'):
globals()['COLOR_PAIRS'] = _curses.COLOR_PAIRS
return retval
# Wrapper for the entire curses-based application. Runs a function which
# should be the rest of your curses-based application. If the application
# raises an exception, wrapper() will restore the terminal to a sane state so
# you can read the resulting traceback.
def wrapper( # pylint: disable=function-redefined
func: _Callable[_Concatenate[window, _P], _T],
/,
*args: _P.args,
**kwds: _P.kwargs,
) -> _T:
"""Wrapper function that initializes curses and calls another function,
restoring normal keyboard/screen behavior on error.
The callable object 'func' is then passed the main window 'stdscr'
as its first argument, followed by any other arguments passed to
wrapper().
"""
from nvitop.tui.library.curses import _curses
try:
# Initialize curses
stdscr = initscr()
# Turn off echoing of keys, and enter cbreak mode,
# where no buffering is performed on keyboard input
_curses.noecho()
_curses.cbreak()
# In keypad mode, escape sequences for special keys
# (like the cursor keys) will be interpreted and
# a special value like curses.KEY_LEFT will be returned
stdscr.keypad(True)
# Start color, too. Harmless if the terminal doesn't have
# color; user can test with has_color() later on. The try/catch
# works around a minor bit of over-conscientiousness in the curses
# module -- the error return from C start_color() is ignorable.
try:
start_color()
except: # noqa: E722,S110,RUF100 # pylint: disable=bare-except
pass
return func(stdscr, *args, **kwds)
finally:
# Set everything back to normal
if 'stdscr' in locals():
stdscr.keypad(False)
_curses.echo()
_curses.nocbreak()
_curses.endwin()
else:
if _TYPE_CHECKING:
from curses import window # pylint: disable=ungrouped-imports

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,213 @@
"""Constants and membership tests for ASCII characters"""
# Copied from the CPython repository.
# https://github.com/python/cpython/blob/HEAD/Lib/curses/ascii.py
# pylint: disable=missing-function-docstring
from __future__ import annotations
from typing import overload
NUL = 0x00 # ^@
SOH = 0x01 # ^A
STX = 0x02 # ^B
ETX = 0x03 # ^C
EOT = 0x04 # ^D
ENQ = 0x05 # ^E
ACK = 0x06 # ^F
BEL = 0x07 # ^G
BS = 0x08 # ^H
TAB = 0x09 # ^I
HT = 0x09 # ^I
LF = 0x0A # ^J
NL = 0x0A # ^J
VT = 0x0B # ^K
FF = 0x0C # ^L
CR = 0x0D # ^M
SO = 0x0E # ^N
SI = 0x0F # ^O
DLE = 0x10 # ^P
DC1 = 0x11 # ^Q
DC2 = 0x12 # ^R
DC3 = 0x13 # ^S
DC4 = 0x14 # ^T
NAK = 0x15 # ^U
SYN = 0x16 # ^V
ETB = 0x17 # ^W
CAN = 0x18 # ^X
EM = 0x19 # ^Y
SUB = 0x1A # ^Z
ESC = 0x1B # ^[
FS = 0x1C # ^\
GS = 0x1D # ^]
RS = 0x1E # ^^
US = 0x1F # ^_
SP = 0x20 # space
DEL = 0x7F # delete
controlnames = [
'NUL',
'SOH',
'STX',
'ETX',
'EOT',
'ENQ',
'ACK',
'BEL',
'BS',
'HT',
'LF',
'VT',
'FF',
'CR',
'SO',
'SI',
'DLE',
'DC1',
'DC2',
'DC3',
'DC4',
'NAK',
'SYN',
'ETB',
'CAN',
'EM',
'SUB',
'ESC',
'FS',
'GS',
'RS',
'US',
'SP',
]
def _ctoi(c: int | str) -> int:
if isinstance(c, str):
return ord(c)
return c
def isalnum(c: int | str) -> bool:
return isalpha(c) or isdigit(c)
def isalpha(c: int | str) -> bool:
return isupper(c) or islower(c)
def isascii(c: int | str) -> bool:
return 0 <= _ctoi(c) <= 127
def isblank(c: int | str) -> bool:
return _ctoi(c) in (9, 32)
def iscntrl(c: int | str) -> bool:
return 0 <= _ctoi(c) <= 31 or _ctoi(c) == 127
def isdigit(c: int | str) -> bool:
return 48 <= _ctoi(c) <= 57
def isgraph(c: int | str) -> bool:
return 33 <= _ctoi(c) <= 126
def islower(c: int | str) -> bool:
return 97 <= _ctoi(c) <= 122
def isprint(c: int | str) -> bool:
return 32 <= _ctoi(c) <= 126
def ispunct(c: int | str) -> bool:
return isgraph(c) and not isalnum(c)
def isspace(c: int | str) -> bool:
return _ctoi(c) in (9, 10, 11, 12, 13, 32)
def isupper(c: int | str) -> bool:
return 65 <= _ctoi(c) <= 90
def isxdigit(c: int | str) -> bool:
return isdigit(c) or (65 <= _ctoi(c) <= 70) or (97 <= _ctoi(c) <= 102)
def isctrl(c: int | str) -> bool:
return 0 <= _ctoi(c) < 32
def ismeta(c: int | str) -> bool:
return _ctoi(c) > 127
@overload
def ascii(c: int) -> int: ... # pylint: disable=redefined-builtin
@overload
def ascii(c: str) -> str: ...
def ascii(c: int | str) -> int | str:
if isinstance(c, str):
return chr(_ctoi(c) & 0x7F)
return _ctoi(c) & 0x7F
@overload
def ctrl(c: int) -> int: ...
@overload
def ctrl(c: str) -> str: ...
def ctrl(c: int | str) -> int | str:
if isinstance(c, str):
return chr(_ctoi(c) & 0x1F)
return _ctoi(c) & 0x1F
@overload
def alt(c: int) -> int: ...
@overload
def alt(c: str) -> str: ...
def alt(c: int | str) -> int | str:
if isinstance(c, str):
return chr(_ctoi(c) | 0x80)
return _ctoi(c) | 0x80
@overload
def unctrl(c: int) -> str: ...
@overload
def unctrl(c: str) -> str: ...
def unctrl(c: int | str) -> str:
bits = _ctoi(c)
if bits == 0x7F:
rep = '^?'
elif isprint(bits & 0x7F):
rep = chr(bits & 0x7F)
else:
rep = '^' + chr(((bits & 0x7F) | 0x20) + 0x20)
if bits & 0x80:
return '!' + rep
return rep

View file

@ -7,12 +7,12 @@
from __future__ import annotations
import copy
import curses
import curses.ascii
import string
from collections import OrderedDict
from typing import TYPE_CHECKING, Callable, Dict, Tuple, Union
from nvitop.tui.library import curses
if TYPE_CHECKING:
from collections.abc import Generator

View file

@ -7,12 +7,12 @@ from __future__ import annotations
import colorsys
import contextlib
import curses
import locale
import os
import signal
from typing import TYPE_CHECKING, Any, ClassVar, Tuple, Union
from nvitop.tui.library import curses
from nvitop.tui.library.history import GRAPH_SYMBOLS
@ -173,15 +173,16 @@ def libcurses(colorful: bool = False, light_theme: bool = False) -> Generator[cu
# Push a Ctrl+C (ascii value 3) to the curses getch stack
def interrupt_handler(*_: Any) -> None: # pylint: disable=unused-argument
curses.ungetch(3)
curses.ungetch(curses.ascii.ETX)
# Simulate a ^C press in curses when an interrupt is caught
signal.signal(signal.SIGINT, interrupt_handler)
original_interrupt_handler = signal.signal(signal.SIGINT, interrupt_handler)
try:
yield win
finally:
curses.endwin()
signal.signal(signal.SIGINT, original_interrupt_handler)
class CursesShortcuts:

View file

@ -6,14 +6,13 @@
from __future__ import annotations
import curses
import string
import threading
import time
from functools import partial
from typing import TYPE_CHECKING, Literal
from nvitop.tui.library import host
from nvitop.tui.library import curses, host
from nvitop.tui.library.displayable import Displayable
from nvitop.tui.library.keybinding import NAMED_SPECIAL_KEYS, normalize_keybinding
from nvitop.tui.library.utils import cut_string

View file

@ -6,9 +6,10 @@
from __future__ import annotations
import curses
from typing import TYPE_CHECKING, ClassVar
from nvitop.tui.library import curses
if TYPE_CHECKING:
from typing_extensions import Self # Python 3.11+

View file

@ -132,9 +132,14 @@ class Selection: # pylint: disable=too-many-instance-attributes
self.foreach(lambda process: process.send_signal(sig))
def interrupt(self) -> None:
try:
sig = (
signal.SIGINT
if not IS_WINDOWS
# pylint: disable-next=no-member
self.send_signal(signal.SIGINT if not IS_WINDOWS else signal.CTRL_C_EVENT) # type: ignore[attr-defined]
else signal.CTRL_C_EVENT # type: ignore[attr-defined,unused-ignore]
)
try:
self.send_signal(sig)
except SystemError:
pass

View file

@ -57,12 +57,12 @@ with contextlib.suppress(AttributeError, OSError):
if IS_WINDOWS:
import ctypes
IS_SUPERUSER = bool(ctypes.windll.shell32.IsUserAnAdmin()) # type: ignore[attr-defined]
IS_SUPERUSER = bool(ctypes.windll.shell32.IsUserAnAdmin()) # type: ignore[attr-defined,unused-ignore]
else:
try:
IS_SUPERUSER = os.geteuid() == 0
IS_SUPERUSER = os.geteuid() == 0 # type: ignore[attr-defined,unused-ignore]
except AttributeError:
IS_SUPERUSER = os.getuid() == 0
IS_SUPERUSER = os.getuid() == 0 # type: ignore[attr-defined,unused-ignore]
HOSTNAME: str = hostname()
IS_WINDOWS_SUBSYSTEM_FOR_LINUX = IS_WSL = bool(WINDOWS_SUBSYSTEM_FOR_LINUX)

View file

@ -6,8 +6,8 @@
from __future__ import annotations
import unicodedata
from typing import TYPE_CHECKING, Literal
from unicodedata import east_asian_width
if TYPE_CHECKING:
@ -18,27 +18,50 @@ __all__ = ['WideString', 'wcslen']
ASCIIONLY: frozenset[str] = frozenset(map(chr, range(1, 128)))
COMBINING: Literal[0] = 0
NARROW: Literal[1] = 1
WIDE: Literal[2] = 2
WIDE_SYMBOLS: frozenset[str] = frozenset('WF')
def utf_char_width(string: str) -> Literal[1, 2]:
"""Return the width of a single character."""
if east_asian_width(string) in WIDE_SYMBOLS:
return WIDE
def utf_char_width(string: str) -> Literal[0, 1, 2]: # pylint: disable=too-many-return-statements
"""Return the width of a single character (0 for combining, 2 for wide, 1 otherwise)."""
try:
import wcwidth # pylint: disable=import-outside-toplevel
w = wcwidth.wcwidth(string)
if w < 0:
return NARROW # control characters treated as width 1
if w == 0:
return COMBINING
if w >= 2:
return WIDE
except ImportError:
# Fallback to unicodedata
if unicodedata.combining(string):
return COMBINING
if unicodedata.east_asian_width(string) in WIDE_SYMBOLS:
return WIDE
return NARROW
def string_to_charlist(string: str) -> list[str]:
"""Return a list of characters with extra empty strings after wide chars."""
"""Return a list of characters with extra empty strings after wide chars.
Combining characters (width 0) are merged with the preceding character.
"""
if ASCIIONLY.issuperset(string):
return list(string)
result = []
result: list[str] = []
for char in string:
result.append(char)
if east_asian_width(char) in WIDE_SYMBOLS:
result.append('')
width = utf_char_width(char)
if width == COMBINING and result:
# Merge combining character with the previous character
result[-1] += char
else:
result.append(char)
if width == WIDE:
result.append('')
return result

View file

@ -5,7 +5,6 @@
from __future__ import annotations
import curses
import shutil
import time
from typing import TYPE_CHECKING, Literal, Union
@ -19,6 +18,7 @@ from nvitop.tui.library import (
MessageBox,
MouseEvent,
Snapshot,
curses,
)
from nvitop.tui.screens import (
BaseScreen,

View file

@ -65,7 +65,7 @@ if not __release__:
# The package `nvidia-ml-py` is not backward compatible over releases. This may
# cause problems with Old versions of NVIDIA drivers.
# cause problems with old versions of NVIDIA drivers.
# The ideal solution is to let the user install the best-fit version of `nvidia-ml-py`.
PYNVML_VERSION_CANDIDATES = (
# Sync with pyproject.toml and requirements.txt
@ -127,3 +127,16 @@ Note:
which are incompatible with some old NVIDIA drivers. ``nvitop`` may not display the processes
correctly due to this incompatibility.
"""
# Check that PYNVML_VERSION_CANDIDATES is sorted.
if not __release__:
try:
from packaging.version import Version as _Version
except ImportError:
pass
else:
assert (
tuple(sorted(PYNVML_VERSION_CANDIDATES, key=_Version)) == PYNVML_VERSION_CANDIDATES
), 'PYNVML_VERSION_CANDIDATES is not sorted.'
del _Version

View file

@ -49,8 +49,7 @@ dependencies = [
# Sync with nvitop/version.py and requirements.txt
"nvidia-ml-py >= 11.450.51, < 13.596.0a0",
"psutil >= 5.6.6",
"colorama >= 0.4.0; platform_system == 'Windows'",
"windows-curses >= 2.2.0; platform_system == 'Windows'",
"colorama >= 0.4.6; platform_system == 'Windows'",
]
dynamic = ["version", "optional-dependencies"]

View file

@ -1,5 +1,4 @@
# Sync with pyproject.toml and nvitop/version.py
nvidia-ml-py >= 11.450.51, < 13.596.0a0
psutil >= 5.6.6
colorama >= 0.4.0; platform_system == 'Windows'
windows-curses >= 2.2.0; platform_system == 'Windows'
colorama >= 0.4.6; platform_system == 'Windows'