Skip to content

Commit e44f2a3

Browse files
authored
Merge pull request #35 from yuvipanda/callable_cpu_limit
Make NBResuse more flexible.
2 parents dd28c87 + edafd63 commit e44f2a3

File tree

5 files changed

+174
-90
lines changed

5 files changed

+174
-90
lines changed

CONTRIBUTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ We recommend using [pipenv](https://docs.pipenv.org/) to make development easier
3232
4. Do a dev install of nbresuse and its dependencies
3333

3434
```bash
35-
pip install --editable .
35+
pip install --editable .[resources]
3636
```
3737

38+
To test the behavior of NBResuse without `psutil` installed, run `pip install --editable .` instead.
39+
3840
5. Install and enable the nbextension for use with Jupyter Classic Notebook.
3941

4042
```bash

nbresuse/__init__.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
from tornado import ioloop
44
from traitlets import Bool
55
from traitlets import default
6+
from traitlets import Dict
67
from traitlets import Float
78
from traitlets import Int
9+
from traitlets import List
10+
from traitlets import TraitType
811
from traitlets import Union
912
from traitlets.config import Configurable
1013

14+
from nbresuse.metrics import PSUtilMetricsLoader
1115
from nbresuse.prometheus import PrometheusHandler
1216

1317
try:
@@ -17,6 +21,21 @@
1721
from .utils import Callable
1822

1923

24+
class PSUtilMetric(TraitType):
25+
"""A trait describing the format to specify a metric from the psutil package"""
26+
27+
info_text = "A dictionary specifying the function/method name, any keyword arguments, and if a named tuple is returned, which attribute of the named tuple to select"
28+
29+
def validate(self, obj, value):
30+
if isinstance(value, dict):
31+
keys = list(value.keys())
32+
if "name" in keys:
33+
keys.remove("name")
34+
if all(key in ["kwargs", "attribute"] for key in keys):
35+
return value
36+
self.error(obj, value)
37+
38+
2039
def _jupyter_server_extension_paths():
2140
"""
2241
Set up the server extension for collecting metrics
@@ -43,6 +62,25 @@ class ResourceUseDisplay(Configurable):
4362
Holds server-side configuration for nbresuse
4463
"""
4564

65+
process_memory_metrics = List(
66+
trait=PSUtilMetric(),
67+
default_value=[{"name": "memory_info", "attribute": "rss"}],
68+
)
69+
70+
system_memory_metrics = List(
71+
trait=PSUtilMetric(),
72+
default_value=[{"name": "virtual_memory", "attribute": "total"}],
73+
)
74+
75+
process_cpu_metrics = List(
76+
trait=PSUtilMetric(),
77+
default_value=[{"name": "cpu_percent", "kwargs": {"interval": 0.05}}],
78+
)
79+
80+
system_cpu_metrics = List(
81+
trait=PSUtilMetric(), default_value=[{"name": "cpu_count"}]
82+
)
83+
4684
mem_warning_threshold = Float(
4785
default_value=0.1,
4886
help="""
@@ -93,7 +131,8 @@ def _mem_limit_default(self):
93131
""",
94132
).tag(config=True)
95133

96-
cpu_limit = Float(
134+
cpu_limit = Union(
135+
trait_types=[Float(), Callable()],
97136
default_value=0,
98137
help="""
99138
CPU usage limit to display to the user.
@@ -116,5 +155,7 @@ def load_jupyter_server_extension(nbapp):
116155
"""
117156
resuseconfig = ResourceUseDisplay(parent=nbapp)
118157
nbapp.web_app.settings["nbresuse_display_config"] = resuseconfig
119-
callback = ioloop.PeriodicCallback(PrometheusHandler(nbapp), 1000)
158+
callback = ioloop.PeriodicCallback(
159+
PrometheusHandler(PSUtilMetricsLoader(nbapp)), 1000
160+
)
120161
callback.start()

nbresuse/metrics.py

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,81 @@
1-
from typing import NamedTuple, Optional
2-
31
try:
42
import psutil
53
except ImportError:
64
psutil = None
75

8-
9-
class MemoryMetrics(NamedTuple):
10-
current_memory: int
11-
max_memory: int
12-
13-
14-
class CPUMetrics(NamedTuple):
15-
cpu_max: float
16-
cpu_usage: float
17-
18-
19-
def memory_metrics() -> Optional[MemoryMetrics]:
20-
if psutil:
21-
cur_process = psutil.Process()
22-
all_processes = [cur_process] + cur_process.children(recursive=True)
23-
24-
rss = sum([p.memory_info().rss for p in all_processes])
25-
virtual_memory = psutil.virtual_memory().total
26-
27-
else:
28-
return None
29-
30-
return MemoryMetrics(rss, virtual_memory)
31-
32-
33-
def cpu_metrics() -> Optional[CPUMetrics]:
34-
if psutil:
35-
cur_process = psutil.Process()
36-
all_processes = [cur_process] + cur_process.children(recursive=True)
37-
38-
cpu_count = psutil.cpu_count()
39-
40-
def get_cpu_percent(p):
41-
try:
42-
return p.cpu_percent(interval=0.05)
43-
# Avoid littering logs with stack traces complaining
44-
# about dead processes having no CPU usage
45-
except BaseException:
46-
return 0
47-
48-
cpu_percent = sum([get_cpu_percent(p) for p in all_processes])
49-
50-
else:
51-
return None
52-
53-
return CPUMetrics(cpu_count * 100.0, cpu_percent)
6+
from notebook.notebookapp import NotebookApp
7+
8+
9+
class PSUtilMetricsLoader:
10+
def __init__(self, nbapp: NotebookApp):
11+
self.config = nbapp.web_app.settings["nbresuse_display_config"]
12+
self.nbapp = nbapp
13+
14+
def process_metric(self, name, kwargs={}, attribute=None):
15+
if psutil is None:
16+
return None
17+
else:
18+
current_process = psutil.Process()
19+
all_processes = [current_process] + current_process.children(recursive=True)
20+
21+
def get_process_metric(process, name, kwargs, attribute=None):
22+
try:
23+
# psutil.Process methods will either return...
24+
metric_value = getattr(process, name)(**kwargs)
25+
if attribute is not None: # ... a named tuple
26+
return getattr(metric_value, attribute)
27+
else: # ... or a number
28+
return metric_value
29+
# Avoid littering logs with stack traces
30+
# complaining about dead processes
31+
except BaseException:
32+
return 0
33+
34+
process_metric_value = lambda process: get_process_metric(
35+
process, name, kwargs, attribute
36+
)
37+
38+
return sum([process_metric_value(process) for process in all_processes])
39+
40+
def system_metric(self, name, kwargs={}, attribute=None):
41+
if psutil is None:
42+
return None
43+
else:
44+
# psutil functions will either return...
45+
metric_value = getattr(psutil, name)(**kwargs)
46+
if attribute is not None: # ... a named tuple
47+
return getattr(metric_value, attribute)
48+
else: # ... or a number
49+
return metric_value
50+
51+
def get_metric_values(self, metrics, metric_type):
52+
metric_types = {"process": self.process_metric, "system": self.system_metric}
53+
metric_value = metric_types[metric_type] # Switch statement
54+
55+
metric_values = {}
56+
for metric in metrics:
57+
name = metric["name"]
58+
if metric.get("attribute", False):
59+
name += "_" + metric.get("attribute")
60+
metric_values.update({name: metric_value(**metric)})
61+
return metric_values
62+
63+
def metrics(self, process_metrics, system_metrics):
64+
65+
metric_values = self.get_metric_values(process_metrics, "process")
66+
metric_values.update(self.get_metric_values(system_metrics, "system"))
67+
68+
if any(value is None for value in metric_values.values()):
69+
return None
70+
71+
return metric_values
72+
73+
def memory_metrics(self):
74+
return self.metrics(
75+
self.config.process_memory_metrics, self.config.system_memory_metrics
76+
)
77+
78+
def cpu_metrics(self):
79+
return self.metrics(
80+
self.config.process_cpu_metrics, self.config.system_cpu_metrics
81+
)

nbresuse/prometheus.py

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,69 @@
1+
from typing import Optional
2+
13
from notebook.notebookapp import NotebookApp
24
from prometheus_client import Gauge
3-
from tornado import gen
4-
from typing import Optional
55

6-
from nbresuse.metrics import cpu_metrics
7-
from nbresuse.metrics import CPUMetrics
8-
from nbresuse.metrics import memory_metrics
9-
from nbresuse.metrics import MemoryMetrics
6+
from nbresuse.metrics import PSUtilMetricsLoader
107

118
try:
129
# Traitlets >= 4.3.3
1310
from traitlets import Callable
1411
except ImportError:
1512
from .utils import Callable
1613

17-
TOTAL_MEMORY_USAGE = Gauge("total_memory_usage", "counter for total memory usage", [])
1814

19-
MAX_MEMORY_USAGE = Gauge("max_memory_usage", "counter for max memory usage", [])
20-
21-
TOTAL_CPU_USAGE = Gauge("total_cpu_usage", "counter for total cpu usage", [])
15+
class PrometheusHandler(Callable):
16+
def __init__(self, metricsloader: PSUtilMetricsLoader):
17+
super().__init__()
18+
self.metricsloader = metricsloader
19+
self.config = metricsloader.config
20+
self.session_manager = metricsloader.nbapp.session_manager
2221

23-
MAX_CPU_USAGE = Gauge("max_cpu_usage", "counter for max cpu usage", [])
22+
self.TOTAL_MEMORY_USAGE = Gauge(
23+
"total_memory_usage", "counter for total memory usage", []
24+
)
25+
self.MAX_MEMORY_USAGE = Gauge(
26+
"max_memory_usage", "counter for max memory usage", []
27+
)
2428

29+
self.TOTAL_CPU_USAGE = Gauge(
30+
"total_cpu_usage", "counter for total cpu usage", []
31+
)
32+
self.MAX_CPU_USAGE = Gauge("max_cpu_usage", "counter for max cpu usage", [])
2533

26-
class PrometheusHandler(Callable):
27-
def __init__(self, nbapp: NotebookApp):
28-
super().__init__()
29-
self.config = nbapp.web_app.settings["nbresuse_display_config"]
30-
self.session_manager = nbapp.session_manager
31-
32-
@gen.coroutine
33-
def __call__(self, *args, **kwargs):
34-
metrics = self.apply_memory_limits(memory_metrics())
35-
if metrics is not None:
36-
TOTAL_MEMORY_USAGE.set(metrics.current_memory)
37-
MAX_MEMORY_USAGE.set(metrics.max_memory)
34+
async def __call__(self, *args, **kwargs):
35+
memory_metric_values = self.metricsloader.memory_metrics()
36+
if memory_metric_values is not None:
37+
self.TOTAL_MEMORY_USAGE.set(memory_metric_values["memory_info_rss"])
38+
self.MAX_MEMORY_USAGE.set(self.apply_memory_limit(memory_metric_values))
3839
if self.config.track_cpu_percent:
39-
metrics = self.apply_cpu_limits(cpu_metrics())
40-
if metrics is not None:
41-
TOTAL_CPU_USAGE.set(metrics.cpu_usage)
42-
MAX_CPU_USAGE.set(metrics.cpu_max)
40+
cpu_metric_values = self.metricsloader.cpu_metrics()
41+
if cpu_metric_values is not None:
42+
self.TOTAL_CPU_USAGE.set(cpu_metric_values["cpu_percent"])
43+
self.MAX_CPU_USAGE.set(self.apply_cpu_limit(cpu_metric_values))
4344

44-
def apply_memory_limits(self, metrics: Optional[MemoryMetrics]) -> Optional[MemoryMetrics]:
45-
if metrics is not None:
45+
def apply_memory_limit(self, memory_metric_values) -> Optional[int]:
46+
if memory_metric_values is None:
47+
return None
48+
else:
4649
if callable(self.config.mem_limit):
47-
metrics.max_memory = self.config.mem_limit(rss=metrics.max_memory)
50+
return self.config.mem_limit(
51+
rss=memory_metric_values["memory_info_rss"]
52+
)
4853
elif self.config.mem_limit > 0: # mem_limit is an Int
49-
metrics.max_memory = self.config.mem_limit
50-
return metrics
51-
52-
def apply_cpu_limits(self, metrics: Optional[CPUMetrics]) -> Optional[CPUMetrics]:
53-
if metrics is not None:
54-
if self.config.cpu_limit > 0:
55-
metrics.cpu_max = self.config.cpu_limit
56-
return metrics
54+
return self.config.mem_limit
55+
else:
56+
return memory_metric_values["virtual_memory_total"]
57+
58+
def apply_cpu_limit(self, cpu_metric_values) -> Optional[float]:
59+
if cpu_metric_values is None:
60+
return None
61+
else:
62+
if callable(self.config.cpu_limit):
63+
return self.config.cpu_limit(
64+
cpu_percent=cpu_metric_values["cpu_percent"]
65+
)
66+
elif self.config.cpu_limit > 0.0: # cpu_limit is a Float
67+
return self.config.cpu_limit
68+
else:
69+
return 100.0 * cpu_metric_values["cpu_count"]

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
setuptools.setup(
1313
name="nbresuse",
14-
version="0.3.4",
14+
version="0.4.0",
1515
url="https://github.com/yuvipanda/nbresuse",
1616
author="Yuvi Panda",
1717
description="Simple Jupyter extension to show how much resources (RAM) your notebook is using",

0 commit comments

Comments
 (0)