Archived
1
1
Fork 0

Allow 'textual run', improve internals

Allow 'textual run', improve internals
This commit is contained in:
Josh Lay 2023-05-05 18:56:20 -05:00 committed by GitHub
commit 654d84014d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 112 additions and 63 deletions

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "amdgpu-stats" name = "amdgpu-stats"
version = "0.1.14" version = "0.1.15"
description = "A module/TUI for AMD GPU statistics" description = "A module/TUI for AMD GPU statistics"
authors = ["Josh Lay <pypi@jlay.io>"] authors = ["Josh Lay <pypi@jlay.io>"]
repository = "https://github.com/joshlay/amdgpu_stats" repository = "https://github.com/joshlay/amdgpu_stats"
@ -14,7 +14,7 @@ textual = ">=0.16.0"
humanfriendly = ">=10.0" humanfriendly = ">=10.0"
[tool.poetry.scripts] [tool.poetry.scripts]
amdgpu-stats = "amdgpu_stats.tui:start" amdgpu-stats = "amdgpu_stats:textual_run"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View file

@ -0,0 +1,26 @@
"""__init__.py for amdgpu-stats"""
import sys
from .tui import app
from .utils import (
AMDGPU_CARDS,
)
def check_for_cards() -> bool:
"""Used by '__main__' and 'textual_run', they should exit w/ a message if no cards
Returns:
bool: If any AMD cards found or not"""
if len(AMDGPU_CARDS) > 0:
return True
return False
def textual_run() -> None:
"""runs the AMD GPU Stats TUI; called only when in an interactive shell"""
if check_for_cards():
amdgpu_stats_app = app(watch_css=True)
amdgpu_stats_app.run()
else:
sys.exit('Could not find an AMD GPU, exiting.')

View file

@ -1,13 +1,12 @@
"""TUI for amdgpu_stats """TUI for amdgpu_stats
This file aims to ensure the TUI only starts in interactive shells""" This file aims to ensure the TUI only starts in interactive shells
from .tui import start
import/use 'amdgpu_stats.utils' to access functions for metrics"""
def main(): from . import textual_run
"""main function, spawns the TUI for amdgpu_stats"""
start()
if __name__ == "__main__": if __name__ == "__main__":
main() textual_run()
else:
pass

View file

@ -40,3 +40,23 @@ Header {
content-align: center middle; content-align: center middle;
background: $panel; background: $panel;
} }
Footer {
background: #073b61;
dock: bottom;
height: 1;
}
.footer--highlight {
background: #81a1c1;
color: #434c5e;
}
.footer--highlight-key {
background: #88c0d0;
color: #434c5e;
}
.footer--key {
background: #004578;
}

View file

@ -14,8 +14,8 @@ Functions:
""" """
# disable superfluouos linting # disable superfluouos linting
# pylint: disable=line-too-long # pylint: disable=line-too-long
import sys
from datetime import datetime from datetime import datetime
from typing import Optional
from rich.text import Text from rich.text import Text
from textual.binding import Binding from textual.binding import Binding
@ -61,39 +61,62 @@ class Notification(Static):
class GPUStatsWidget(Static): class GPUStatsWidget(Static):
"""The main stats widget.""" """The main stats widget."""
# define the columns for the stats table; used as keys during update def get_column_data_mapping(self, card: Optional[str] = None) -> dict:
columns = ["Card", '''Returns a dictionary of stats
"Core clock",
"Memory clock", Columns are derived from keys, and values provide measurements
"Utilization", *Measurements require `card`*'''
"Voltage", if card is None:
"Power", return {
"[italic]Limit", "Card": "",
"[italic]Default", "Core clock": "",
"[italic]Capability", "Memory clock": "",
"Fan RPM", "Utilization": "",
"Edge temp", "Voltage": "",
"Junction temp", "Power": "",
"Memory temp"] "[italic]Limit": "",
# initialize empty/default instance vars "[italic]Default": "",
"[italic]Capability": "",
"Fan RPM": "",
"Edge temp": "",
"Junction temp": "",
"Memory temp": ""
}
return {
"Card": card,
"Core clock": get_clock('core', card=card, format_freq=True),
"Memory clock": get_clock('memory', card=card, format_freq=True),
"Utilization": f'{get_gpu_usage(card=card)}%',
"Voltage": f'{get_voltage(card=card)}V',
"Power": f'{get_power_stats(card=card)["average"]}W',
"[italic]Limit": f'{get_power_stats(card=card)["limit"]}W',
"[italic]Default": f'{get_power_stats(card=card)["default"]}W',
"[italic]Capability": f'{get_power_stats(card=card)["capability"]}W',
"Fan RPM": f'{get_fan_rpm(card=card)}',
"Edge temp": f"{get_temp_stat(name='edge', card=card)}C",
"Junction temp": f"{get_temp_stat(name='junction', card=card)}C",
"Memory temp": f"{get_temp_stat(name='mem', card=card)}C"
}
# initialize empty/default instance vars and objects
data = {} data = {}
stats_table = None stats_table = None
text_log = None
tabbed_container = None tabbed_container = None
table = None text_log = None
timer_stats = None
# mark the table as needing initialization (with rows) # mark the table as needing initialization (with rows)
table_needs_init = True table_needs_init = True
timer_stats = None
def __init__(self, *args, cards=None, **kwargs): def __init__(self, *args, cards=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.cards = cards self.cards = cards
self.text_log = TextLog(highlight=True, self.text_log = TextLog(highlight=True,
markup=True, markup=True,
name='log_gpu', name='log_gpu',
classes='logs') classes='logs')
self.stats_table = DataTable(zebra_stripes=True, self.stats_table = DataTable(zebra_stripes=True,
show_cursor=False, show_cursor=True,
name='stats_table', name='stats_table',
classes='stat_table') classes='stat_table')
self.tabbed_container = TabbedContent() self.tabbed_container = TabbedContent()
@ -101,12 +124,13 @@ class GPUStatsWidget(Static):
async def on_mount(self) -> None: async def on_mount(self) -> None:
'''Fires when stats widget 'mounted', behaves like on first showing''' '''Fires when stats widget 'mounted', behaves like on first showing'''
# construct the table columns # construct the table columns
for column in self.columns: columns = list(self.get_column_data_mapping(None).keys())
for column in columns:
self.stats_table.add_column(label=column, key=column) self.stats_table.add_column(label=column, key=column)
# do a one-off stat collection, populate table before the interval # do a one-off stat collection, populate table before the interval
self.get_stats() self.get_stats()
# stand up the stat-collecting interval, once per second # stand up the stat-collecting interval, twice per second
self.timer_stats = self.set_interval(1, self.get_stats) self.timer_stats = self.set_interval(0.5, self.get_stats)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets.""" """Create child widgets."""
@ -127,25 +151,11 @@ class GPUStatsWidget(Static):
def get_stats(self): def get_stats(self):
'''Function to fetch stats / update the table for each AMD GPU found''' '''Function to fetch stats / update the table for each AMD GPU found'''
for card in self.cards: for card in self.cards:
power_stats = get_power_stats(card=card)
# annoyingly, must retain the styling used w/ the cols above # annoyingly, must retain the styling used w/ the cols above
# otherwise stats won't update # otherwise stats won't update
# noticed when fiddling 'style' below between new/update 'Text' # noticed when fiddling 'style' below between new/update 'Text'
# should store their IDs on creation and map those instead # should store their IDs on creation and map those instead
self.data = { self.data = self.get_column_data_mapping(card)
"Card": card,
"Core clock": get_clock('core', card=card, format_freq=True),
"Memory clock": get_clock('memory', card=card, format_freq=True),
"Utilization": f'{get_gpu_usage(card=card)}%',
"Voltage": f'{get_voltage(card=card)}V',
"Power": f'{power_stats["average"]}W',
"[italic]Limit": f'{power_stats["limit"]}W',
"[italic]Default": f'{power_stats["default"]}W',
"[italic]Capability": f'{power_stats["capability"]}W',
"Fan RPM": f'{get_fan_rpm(card=card)}',
"Edge temp": f"{get_temp_stat(name='edge', card=card)}C",
"Junction temp": f"{get_temp_stat(name='junction', card=card)}C",
"Memory temp": f"{get_temp_stat(name='mem', card=card)}C"}
# handle the table data appopriately # handle the table data appopriately
# if needs populated anew or updated # if needs populated anew or updated
if self.table_needs_init: if self.table_needs_init:
@ -167,10 +177,11 @@ class GPUStatsWidget(Static):
self.table_needs_init = False self.table_needs_init = False
class AMDGPUStats(App): class app(App): # pylint: disable=invalid-name
"""Textual-based tool to show AMDGPU statistics.""" """Textual-based tool to show AMDGPU statistics."""
# apply stylesheet # apply stylesheet; this is watched/dynamically reloaded
# can be edited (in installation dir) and seen live
CSS_PATH = 'style.css' CSS_PATH = 'style.css'
# set the title - same as the class, but with spaces # set the title - same as the class, but with spaces
@ -179,23 +190,25 @@ class AMDGPUStats(App):
# setup keybinds # setup keybinds
BINDINGS = [ BINDINGS = [
Binding("c", "custom_dark", "Colors"), Binding("c", "custom_dark", "Colors"),
Binding("l", "custom_log", "Logs"), Binding("t", "custom_tab", "Tab switch"),
Binding("s", "custom_screenshot", "Screenshot"), Binding("s", "custom_screenshot", "Screenshot"),
Binding("q", "quit", "Quit") Binding("q", "quit", "Quit")
] ]
# create an instance of the stats widget with all cards
stats_widget = GPUStatsWidget(cards=AMDGPU_CARDS, stats_widget = GPUStatsWidget(cards=AMDGPU_CARDS,
name="stats_widget") name="stats_widget")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" """Create child widgets for the app."""
yield Header(show_clock=True) yield Header(show_clock=True)
yield Footer()
yield Container(self.stats_widget) yield Container(self.stats_widget)
yield Footer()
def action_custom_dark(self) -> None: def action_custom_dark(self) -> None:
"""An action to toggle dark mode. """An action to toggle dark mode.
Wraps 'action_toggle_dark' with logging and a refresh""" Wraps 'action_toggle_dark' with our logging"""
self.app.dark = not self.app.dark self.app.dark = not self.app.dark
self.update_log(f"[bold]Dark side: [italic]{self.app.dark}") self.update_log(f"[bold]Dark side: [italic]{self.app.dark}")
@ -212,8 +225,8 @@ class AMDGPUStats(App):
self.screen.mount(Notification(message)) self.screen.mount(Notification(message))
self.update_log(message) self.update_log(message)
def action_custom_log(self) -> None: def action_custom_tab(self) -> None:
"""Toggle between the main screen and the LogScreen.""" """Toggle between the 'Stats' and 'Logs' tabs"""
if self.stats_widget.tabbed_container.active == "tab_stats": if self.stats_widget.tabbed_container.active == "tab_stats":
self.stats_widget.tabbed_container.active = 'tab_logs' self.stats_widget.tabbed_container.active = 'tab_logs'
else: else:
@ -222,12 +235,3 @@ class AMDGPUStats(App):
def update_log(self, message: str) -> None: def update_log(self, message: str) -> None:
"""Update the TextLog widget with a new message.""" """Update the TextLog widget with a new message."""
self.stats_widget.text_log.write(message) self.stats_widget.text_log.write(message)
def start() -> None:
'''Spawns the textual UI only during CLI invocation / after argparse'''
if len(AMDGPU_CARDS) > 0:
app = AMDGPUStats(watch_css=True)
app.run()
else:
sys.exit('Could not find an AMD GPU, exiting.')