13 Commits

Author SHA1 Message Date
f5225f88f9 Add max CPU percentage 2013-07-17 18:48:55 -04:00
32e59310ef Fix for dead processes 2013-07-17 18:19:52 -04:00
5a33ef48cc Add /process/info request 2013-07-17 18:12:44 -04:00
18a5cd6334 Improve boolean parameter parsing 2013-07-15 14:39:28 -04:00
7ec4d60d38 Fix WSGI docs 2013-07-11 16:36:18 -04:00
b2bdf784ac Make test URLs relative 2013-07-11 11:46:02 -04:00
e0709f0d17 Remove multiprocessing due to mod_wsgi incompatibility; use subprocess
Multiprocessing and Apache's mod_wsgi don't play nicely.  Switch to
manually managing processes via subprocess.Popen etc instead.  When
running arbitrary code, we write it to an external file, and running
functions directly is no longer supported.
2013-07-11 11:39:22 -04:00
18d3cff772 Update WSGI docs 2013-07-10 14:16:35 -04:00
a7b9656916 Remove parameters from status output 2013-07-10 11:35:17 -04:00
2e9ec63675 Don't catch SystemExit from a subprocess 2013-07-09 13:15:27 -04:00
6d295b840a Delete trainola; it will live in nilmtools repo 2013-07-08 11:57:45 -04:00
74a05d05d6 Clear out traceback object to avoid reference cycles 2013-07-08 11:44:19 -04:00
35b20c90a5 Rename and reorganize stuff 2013-07-08 11:33:27 -04:00
10 changed files with 401 additions and 607 deletions

View File

@@ -9,7 +9,8 @@ Prerequisites:
# Base dependencies # Base dependencies
sudo apt-get install python-numpy python-scipy sudo apt-get install python-numpy python-scipy
nilmdb (1.8.0+) # Plus nilmdb and its dependencies
nilmdb (1.8.2+)
Install: Install:

View File

@@ -21,13 +21,13 @@ arbitrary commands.
SSLEngine On SSLEngine On
WSGIScriptAlias /nilmrun /home/nilm/nilmrun.wsgi WSGIScriptAlias /nilmrun /home/nilm/nilmrun.wsgi
WSGIApplicationGroup nilmrun-appgroup
WSGIProcessGroup nilmrun-procgroup
WSGIDaemonProcess nilmrun-procgroup threads=32 user=nilm group=nilm WSGIDaemonProcess nilmrun-procgroup threads=32 user=nilm group=nilm
# Access control example:
<Location /nilmrun> <Location /nilmrun>
WSGIProcessGroup nilmrun-procgroup
WSGIApplicationGroup nilmrun-appgroup
SSLRequireSSL SSLRequireSSL
# Access control example:
Order deny,allow Order deny,allow
Deny from all Deny from all
Allow from 1.2.3.4 Allow from 1.2.3.4

View File

@@ -1 +0,0 @@
# Filters

View File

@@ -1,260 +0,0 @@
#!/usr/bin/python
from nilmdb.utils.printf import *
import nilmdb.client
import nilmtools.filter
from nilmdb.utils.time import (timestamp_to_human,
timestamp_to_seconds,
seconds_to_timestamp)
from nilmdb.utils.interval import Interval
import numpy as np
import scipy
import scipy.signal
from numpy.core.umath_tests import inner1d
import nilmrun
from collections import OrderedDict
class DataError(ValueError):
pass
class Data(object):
def __init__(self, name, url, stream, start, end, columns):
"""Initialize, get stream info, check columns"""
self.name = name
self.url = url
self.stream = stream
self.start = start
self.end = end
# Get stream info
self.client = nilmdb.client.numpyclient.NumpyClient(url)
self.info = nilmtools.filter.get_stream_info(self.client, stream)
# Build up name => index mapping for the columns
self.columns = OrderedDict()
for c in columns:
if (c['name'] in self.columns.keys() or
c['index'] in self.columns.values()):
raise DataError("duplicated columns")
if (c['index'] < 0 or c['index'] >= self.info.layout_count):
raise DataError("bad column number")
self.columns[c['name']] = c['index']
if not len(self.columns):
raise DataError("no columns")
# Count points
self.count = self.client.stream_count(self.stream, self.start, self.end)
def __str__(self):
return sprintf("%-20s: %s%s, %s rows",
self.name, self.stream, str(self.columns.keys()),
self.count)
def fetch(self, min_rows = 10, max_rows = 100000):
"""Fetch all the data into self.data. This is intended for
exemplars, and can only handle a relatively small number of
rows"""
# Verify count
if self.count == 0:
raise DataError("No data in this exemplar!")
if self.count < min_rows:
raise DataError("Too few data points: " + str(self.count))
if self.count > max_rows:
raise DataError("Too many data points: " + str(self.count))
# Extract the data
datagen = self.client.stream_extract_numpy(self.stream,
self.start, self.end,
self.info.layout,
maxrows = self.count)
self.data = list(datagen)[0]
# Discard timestamp
self.data = self.data[:,1:]
# Subtract the mean from each column
self.data = self.data - self.data.mean(axis=0)
# Get scale factors for each column by computing dot product
# of each column with itself.
self.scale = inner1d(self.data.T, self.data.T)
# Ensure a minimum (nonzero) scale and convert to list
self.scale = np.maximum(self.scale, [1e-9]).tolist()
def process(main, function, args = None, rows = 200000):
"""Process through the data; similar to nilmtools.Filter.process_numpy"""
if args is None:
args = []
extractor = main.client.stream_extract_numpy
old_array = np.array([])
for new_array in extractor(main.stream, main.start, main.end,
layout = main.info.layout, maxrows = rows):
# If we still had old data left, combine it
if old_array.shape[0] != 0:
array = np.vstack((old_array, new_array))
else:
array = new_array
# Process it
processed = function(array, args)
# Save the unprocessed parts
if processed >= 0:
old_array = array[processed:]
else:
raise Exception(sprintf("%s return value %s must be >= 0",
str(function), str(processed)))
# Warn if there's too much data remaining
if old_array.shape[0] > 3 * rows:
printf("warning: %d unprocessed rows in buffer\n",
old_array.shape[0])
# Handle leftover data
if old_array.shape[0] != 0:
processed = function(array, args)
def peak_detect(data, delta):
"""Simple min/max peak detection algorithm, taken from my code
in the disagg.m from the 10-8-5 paper"""
mins = [];
maxs = [];
cur_min = (None, np.inf)
cur_max = (None, -np.inf)
lookformax = False
for (n, p) in enumerate(data):
if p > cur_max[1]:
cur_max = (n, p)
if p < cur_min[1]:
cur_min = (n, p)
if lookformax:
if p < (cur_max[1] - delta):
maxs.append(cur_max)
cur_min = (n, p)
lookformax = False
else:
if p > (cur_min[1] + delta):
mins.append(cur_min)
cur_max = (n, p)
lookformax = True
return (mins, maxs)
def match(data, args):
"""Perform cross-correlation match"""
( columns, exemplars ) = args
nrows = data.shape[0]
# We want at least 10% more points than the widest exemplar.
widest = max([ x.count for x in exemplars ])
if (widest * 1.1) > nrows:
return 0
# This is how many points we'll consider valid in the
# cross-correlation.
valid = nrows + 1 - widest
matches = []
# Try matching against each of the exemplars
for e_num, e in enumerate(exemplars):
corrs = []
# Compute cross-correlation for each column
for c in e.columns:
a = data[:,columns[c] + 1]
b = e.data[:,e.columns[c]]
corr = scipy.signal.fftconvolve(a, np.flipud(b), 'valid')[0:valid]
# Scale by the norm of the exemplar
corr = corr / e.scale[columns[c]]
corrs.append(corr)
# Find the peaks using the column with the largest amplitude
biggest = e.scale.index(max(e.scale))
peaks_minmax = peak_detect(corrs[biggest], 0.1)
peaks = [ p[0] for p in peaks_minmax[1] ]
# Now look at every peak
for p in peaks:
# Correlation for each column must be close enough to 1.
for (corr, scale) in zip(corrs, e.scale):
# The accepted distance from 1 is based on the relative
# amplitude of the column. Use a linear mapping:
# scale 1.0 -> distance 0.1
# scale 0.0 -> distance 1.0
distance = 1 - 0.9 * (scale / e.scale[biggest])
if abs(corr[p] - 1) > distance:
# No match
break
else:
# Successful match
matches.append((p, e_num))
# Print matches
for (point, e_num) in sorted(matches):
# Ignore matches that showed up at the very tail of the window,
# and shorten the window accordingly. This is an attempt to avoid
# problems at chunk boundaries.
if point > (valid - 50):
valid -= 50
break
print "matched", data[point,0], "exemplar", exemplars[e_num].name
#from matplotlib import pyplot as p
#p.plot(data[:,1:3])
#p.show()
return max(valid, 0)
def trainola(conf):
# Load main stream data
print "Loading stream data"
main = Data(None, conf['url'], conf['stream'],
conf['start'], conf['end'], conf['columns'])
# Pull in the exemplar data
exemplars = []
for n, e in enumerate(conf['exemplars']):
print sprintf("Loading exemplar %d: %s", n, e['name'])
ex = Data(e['name'], e['url'], e['stream'],
e['start'], e['end'], e['columns'])
ex.fetch()
exemplars.append(ex)
# Verify that the exemplar columns are all represented in the main data
for n, ex in enumerate(exemplars):
for col in ex.columns:
if col not in main.columns:
raise DataError(sprintf("Exemplar %d column %s is not "
"available in main data", n, col))
# Process the main data
process(main, match, (main.columns, exemplars))
return "done"
filterfunc = trainola
def main(argv = None):
import simplejson as json
import argparse
import sys
parser = argparse.ArgumentParser(
formatter_class = argparse.RawDescriptionHelpFormatter,
version = nilmrun.__version__,
description = """Run Trainola using parameters passed in as
JSON-formatted data.""")
parser.add_argument("file", metavar="FILE", nargs="?",
type=argparse.FileType('r'), default=sys.stdin)
args = parser.parse_args(argv)
conf = json.loads(args.file.read())
result = trainola(conf)
print json.dumps(result, sort_keys = True, indent = 2 * ' ')
if __name__ == "__main__":
main()

View File

@@ -3,7 +3,7 @@
from nilmdb.utils.printf import * from nilmdb.utils.printf import *
import threading import threading
import multiprocessing import subprocess
import cStringIO import cStringIO
import sys import sys
import os import os
@@ -11,8 +11,12 @@ import signal
import time import time
import uuid import uuid
import psutil import psutil
import imp import tempfile
import traceback import atexit
import shutil
class ProcessError(Exception):
pass
class LogReceiver(object): class LogReceiver(object):
"""Spawn a thread that listens to a pipe for log messages, """Spawn a thread that listens to a pipe for log messages,
@@ -38,9 +42,8 @@ class LogReceiver(object):
self.log = cStringIO.StringIO() self.log = cStringIO.StringIO()
class Process(object): class Process(object):
"""Spawn and manage a process that calls a Python function""" """Spawn and manage a subprocess, and capture its output."""
def __init__(self, name, function, parameters): def __init__(self, name, argv, tempfile = None):
self.parameters = parameters
self.start_time = None self.start_time = None
self.name = name self.name = name
@@ -48,97 +51,72 @@ class Process(object):
(rpipe, wpipe) = os.pipe() (rpipe, wpipe) = os.pipe()
self._log = LogReceiver(rpipe) self._log = LogReceiver(rpipe)
# Start the function in a new process # Stdin is null
self._process = multiprocessing.Process( nullfd = os.open(os.devnull, os.O_RDONLY)
target = self._trampoline, name = name,
args = (function, rpipe, wpipe, parameters))
self._process.daemon = True
self._process.start()
# Close the writer end of the pipe, get process info # Spawn the new process
os.close(wpipe) try:
self._process = subprocess.Popen(args = argv, stdin = nullfd,
stdout = wpipe, stderr = wpipe,
close_fds = True, cwd = "/tmp")
except (OSError, TypeError) as e:
raise ProcessError(str(e))
finally:
# Close the FDs we don't need
os.close(wpipe)
os.close(nullfd)
# Get process info
self.start_time = time.time() self.start_time = time.time()
self.pid = str(uuid.uuid1(self._process.pid or 0)) self.pid = str(uuid.uuid1(self._process.pid or 0))
def _trampoline(self, func, rpipe, wpipe, param): # pragma: no cover def _join(self, timeout = 1.0):
# No coverage report for this, because it's executed in a subprocess start = time.time()
"""Trampoline function to set up stdio and call the real function.""" while True:
# Close the reader end of the pipe if self._process.poll() is not None:
os.close(rpipe) return True
if (time.time() - start) >= timeout:
# Like os.close() but ignores errors return False
def tryclose(fd): time.sleep(0.1)
try:
os.close(fd)
except OSError:
pass
# Remap stdio to go to the pipe. We do this at the OS level,
# replacing FDs, so that future spawned processes do the right thing.
# stdin
sys.stdin.close()
tryclose(0)
fd = os.open(os.devnull, os.O_RDONLY) # 0
sys.stdin = os.fdopen(fd, 'r', 0)
# stdout
sys.stdout.close()
tryclose(1)
fd = os.dup(wpipe) # 1
sys.stdout = os.fdopen(fd, 'w', 0)
# stdout
sys.stderr.close()
tryclose(2)
fd = os.dup(wpipe) # 2
sys.stderr = os.fdopen(fd, 'w', 0)
# Don't need this extra fd
os.close(wpipe)
# Ready to go -- call the function, exit when it's done
func(param)
sys.exit(0)
def terminate(self, timeout = 1.0): def terminate(self, timeout = 1.0):
"""Terminate a process, and all of its children that are in the same """Terminate a process, and all of its children that are in the same
process group.""" process group."""
# First give it some time to die on its own try:
self._process.join(timeout) # First give it some time to die on its own
if not self.alive: if self._join(timeout):
return True
def getpgid(pid):
try:
return os.getpgid(pid)
except OSError: # pragma: no cover
return None
# Find all children
group = getpgid(self._process.pid)
main = psutil.Process(self._process.pid)
allproc = [ main ] + main.get_children(recursive = True)
# Kill with SIGTERM, if they're still in this process group
for proc in allproc:
if getpgid(proc.pid) == group:
os.kill(proc.pid, signal.SIGTERM)
# Wait for it to die again
if self._join(timeout):
return True
# One more try with SIGKILL
for proc in allproc:
if getpgid(proc.pid) == group:
os.kill(proc.pid, signal.SIGKILL)
# See if it worked
return self._join(timeout)
except psutil.Error: # pragma: no cover (race condition)
return True return True
def getpgid(pid):
try:
return os.getpgid(pid)
except OSError: # pragma: no cover
return None
# Find all children
group = getpgid(self._process.pid)
main = psutil.Process(self._process.pid)
allproc = [ main ] + main.get_children(recursive = True)
# Kill with SIGTERM, if they're still in this process group
for proc in allproc:
if getpgid(proc.pid) == group:
os.kill(proc.pid, signal.SIGTERM)
# Wait for it to die again
self._process.join(timeout)
if not self.alive:
return True
# One more try with SIGKILL
for proc in allproc:
if getpgid(proc.pid) == group:
os.kill(proc.pid, signal.SIGKILL)
# See if it worked
self._process.join(timeout)
return not self.alive
def clear_log(self): def clear_log(self):
self._log.clear() self._log.clear()
@@ -148,59 +126,62 @@ class Process(object):
@property @property
def alive(self): def alive(self):
return self._process.is_alive() return self._process.poll() is None
@property @property
def exitcode(self): def exitcode(self):
return self._process.exitcode return self._process.returncode
def _exec_user_code(codeargs): # pragma: no cover (runs in subprocess) def get_info_prepare(self):
"""Execute 'code' as if it were placed into a file and executed""" """Prepare the process list and measurement for .get_info.
(code, args) = codeargs Call .get_info() about a second later."""
# This is split off into a separate function because the Python3 try:
# syntax of "exec" triggers a SyntaxError in Python2, if it's within main = psutil.Process(self._process.pid)
# a nested function. self._process_list = [ main ] + main.get_children(recursive = True)
imp.acquire_lock() for proc in self._process_list:
try: proc.get_cpu_percent(0)
module = imp.new_module("__main__") except psutil.Error: # pragma: no cover (race condition)
finally: self._process_list = [ ]
imp.release_lock()
module.__file__ = "<user-code>"
sys.argv = [''] + args
# Wrap the compile and exec in a try/except so we can format the
# exception more nicely.
try:
codeobj = compile(code, '<user-code>', 'exec',
flags = 0, dont_inherit = 1)
exec(codeobj, module.__dict__, {})
except:
# Pull out the exception
info = sys.exc_info()
tblist = traceback.extract_tb(info[2])
# First entry is probably this code; get rid of it @staticmethod
if len(tblist) and tblist[0][2] == '_exec_user_code': def get_empty_info():
tblist = tblist[1:] return { "cpu_percent": 0,
"cpu_user": 0,
"cpu_sys": 0,
"mem_phys": 0,
"mem_virt": 0,
"io_read": 0,
"io_write": 0,
"procs": 0 }
# Add the user's source code to every line that's missing it def get_info(self):
lines = code.splitlines() """Return a dictionary with info about the process CPU and memory
for (n, (name, line, func, text)) in enumerate(tblist): usage. Call .get_info_prepare() about a second before this."""
if name == '<user-code>' and text is None and line <= len(lines): d = self.get_empty_info()
tblist[n] = (name, line, func, lines[line-1].strip()) for proc in self._process_list:
try:
# Print it to stderr in the usual format d["cpu_percent"] += proc.get_cpu_percent(0)
out = ['Traceback (most recent call last):\n'] cpuinfo = proc.get_cpu_times()
out.extend(traceback.format_list(tblist)) d["cpu_user"] += cpuinfo.user
out.extend(traceback.format_exception_only(info[0], info[1])) d["cpu_sys"] += cpuinfo.system
sys.stderr.write("".join(out)) meminfo = proc.get_memory_info()
sys.stderr.flush() d["mem_phys"] += meminfo.rss
sys.exit(1) d["mem_virt"] += meminfo.vms
sys.exit(0) ioinfo = proc.get_io_counters()
d["io_read"] += ioinfo.read_bytes
d["io_write"] += ioinfo.write_bytes
d["procs"] += 1
except psutil.Error:
pass
return d
class ProcessManager(object): class ProcessManager(object):
"""Track and manage a collection of Process objects""" """Track and manage a collection of Process objects"""
def __init__(self): def __init__(self):
self.processes = {} self.processes = {}
self.tmpfiles = {}
self.tmpdir = tempfile.mkdtemp(prefix = "nilmrun-usercode-")
atexit.register(shutil.rmtree, self.tmpdir)
def __iter__(self): def __iter__(self):
return iter(self.processes.keys()) return iter(self.processes.keys())
@@ -208,35 +189,69 @@ class ProcessManager(object):
def __getitem__(self, key): def __getitem__(self, key):
return self.processes[key] return self.processes[key]
def run_function(self, procname, function, parameters):
"""Run a Python function that already exists"""
new = Process(procname, function, parameters)
self.processes[new.pid] = new
return new.pid
def run_code(self, procname, code, args): def run_code(self, procname, code, args):
"""Evaluate 'code' as if it were placed into a Python file and """Evaluate 'code' as if it were placed into a Python file and
executed. The arguments will be accessible in the code as executed. The arguments, which must be strings, will be
sys.argv[1:].""" accessible in the code as sys.argv[1:]."""
return self.run_function(procname, _exec_user_code, (code, args)) # The easiest way to do this, by far, is to just write the
# code to a file.
(fd, path) = tempfile.mkstemp(prefix = "nilmrun-usercode-",
suffix = ".py", dir=self.tmpdir)
with os.fdopen(fd, 'w') as f:
f.write(code)
argv = [ sys.executable, "-B", "-s", "-u", path ] + args
pid = self.run_command(procname, argv)
self.tmpfiles[pid] = path
return pid
def run_command(self, procname, argv): def run_command(self, procname, argv):
"""Execute a command line program""" """Execute a command line program"""
def spwan_user_command(argv): # pragma: no cover (runs in subprocess) new = Process(procname, argv)
try: self.processes[new.pid] = new
maxfd = os.sysconf("SC_OPEN_MAX") return new.pid
except Exception:
maxfd = 256
os.closerange(3, maxfd)
try:
os.chdir("/tmp")
except OSError:
pass
os.execvp(argv[0], argv)
return self.run_function(procname, spwan_user_command, argv)
def terminate(self, pid): def terminate(self, pid):
return self.processes[pid].terminate() return self.processes[pid].terminate()
def remove(self, pid): def remove(self, pid):
if pid in self.tmpfiles:
try:
os.unlink(self.tmpfiles[pid])
except OSError: # pragma: no cover
pass
del self.tmpfiles[pid]
del self.processes[pid] del self.processes[pid]
def get_info(self):
"""Get info about all running PIDs"""
info = { "total" : Process.get_empty_info(),
"pids" : {},
"system" : {}
}
# Trigger CPU usage collection
for pid in self:
self[pid].get_info_prepare()
psutil.cpu_percent(0, percpu = True)
# Give it some time
time.sleep(1)
# Retrieve info for system
info["system"]["cpu_percent"] = sum(psutil.cpu_percent(0, percpu=True))
info["system"]["cpu_max"] = 100.0 * psutil.NUM_CPUS
info["system"]["procs"] = len(psutil.get_pid_list())
# psutil > 0.6.0's psutil.virtual_memory() would be better here,
# but this should give the same info.
meminfo = psutil.phymem_usage()
info["system"]["mem_total"] = meminfo.total
info["system"]["mem_used"] = int(meminfo.total * meminfo.percent / 100)
# Retrieve info for each PID
for pid in self:
info["pids"][pid] = self[pid].get_info()
# Update totals
for key in info["total"]:
info["total"][key] += info["pids"][pid][key]
return info

View File

@@ -23,10 +23,10 @@ from nilmdb.server.serverutil import (
json_error_page, json_error_page,
cherrypy_start, cherrypy_start,
cherrypy_stop, cherrypy_stop,
bool_param,
) )
import nilmrun import nilmrun
import nilmrun.filters.trainola import nilmrun.testfilter
import nilmrun.filters.dummy
# Add CORS_allow tool # Add CORS_allow tool
cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow) cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)
@@ -69,7 +69,6 @@ class AppProcess(object):
"exitcode": self.manager[pid].exitcode, "exitcode": self.manager[pid].exitcode,
"name": self.manager[pid].name, "name": self.manager[pid].name,
"start_time": self.manager[pid].start_time, "start_time": self.manager[pid].start_time,
"parameters": self.manager[pid].parameters,
"log": self.manager[pid].log, "log": self.manager[pid].log,
} }
@@ -79,6 +78,7 @@ class AppProcess(object):
def status(self, pid, clear = False): def status(self, pid, clear = False):
"""Return status about a process. If clear = True, also clear """Return status about a process. If clear = True, also clear
the log.""" the log."""
clear = bool_param(clear)
if pid not in self.manager: if pid not in self.manager:
raise cherrypy.HTTPError("404 Not Found", "No such PID") raise cherrypy.HTTPError("404 Not Found", "No such PID")
status = self.process_status(pid) status = self.process_status(pid)
@@ -93,6 +93,14 @@ class AppProcess(object):
"""Return a list of processes in the manager.""" """Return a list of processes in the manager."""
return list(self.manager) return list(self.manager)
# /process/info
@cherrypy.expose
@cherrypy.tools.json_out()
def info(self):
"""Return detailed CPU and memory info about the system and
all processes"""
return self.manager.get_info()
# /process/remove # /process/remove
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_in() @cherrypy.tools.json_in()
@@ -117,44 +125,34 @@ class AppRun(object):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_in() @cherrypy.tools.json_in()
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@exception_to_httperror(nilmrun.processmanager.ProcessError)
@cherrypy.tools.CORS_allow(methods = ["POST"]) @cherrypy.tools.CORS_allow(methods = ["POST"])
def command(self, argv): def command(self, argv):
"""Execute an arbitrary program on the server. argv is a """Execute an arbitrary program on the server. argv is a
list of the program and its arguments: 'argv[0]' is the program list of the program and its arguments: 'argv[0]' is the program
and 'argv[1:]' are arguments""" and 'argv[1:]' are arguments"""
if not isinstance(argv, list):
raise cherrypy.HTTPError("400 Bad Request",
"argv must be a list of strings")
return self.manager.run_command("command", argv) return self.manager.run_command("command", argv)
# /run/code
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_in() @cherrypy.tools.json_in()
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@exception_to_httperror(nilmrun.processmanager.ProcessError)
@cherrypy.tools.CORS_allow(methods = ["POST"]) @cherrypy.tools.CORS_allow(methods = ["POST"])
def code(self, code, args): def code(self, code, args):
"""Execute arbitrary Python code. 'code' is a formatted string. """Execute arbitrary Python code. 'code' is a formatted string.
It will be run as if it were written into a Python file and It will be run as if it were written into a Python file and
executed, with the arguments in 'args' passed on the command line executed. 'args' is a list of strings, and they are passed
(i.e., they end up in sys.argv[1:])""" on the command line as additional arguments (i.e., they end up
in sys.argv[1:])"""
if not isinstance(args, list):
raise cherrypy.HTTPError("400 Bad Request",
"args must be a list of strings")
return self.manager.run_code("usercode", code, args) return self.manager.run_code("usercode", code, args)
# /run/trainola
@cherrypy.expose
@cherrypy.tools.json_in()
@cherrypy.tools.json_out()
@exception_to_httperror(KeyError, ValueError)
@cherrypy.tools.CORS_allow(methods = ["POST"])
def trainola(self, data):
return self.manager.run_function(
"trainola", nilmrun.filters.trainola.filterfunc, data)
# /run/dummy
@cherrypy.expose
@cherrypy.tools.json_in()
@cherrypy.tools.json_out()
@exception_to_httperror(KeyError, ValueError)
@cherrypy.tools.CORS_allow(methods = ["POST"])
def dummy(self, data):
return self.manager.run_function(
"dummy", nilmrun.filters.dummy.filterfunc, data)
class Server(object): class Server(object):
def __init__(self, host = '127.0.0.1', port = 8080, def __init__(self, host = '127.0.0.1', port = 8080,
embedded = True, # hide diagnostics and output, etc embedded = True, # hide diagnostics and output, etc

View File

@@ -6,7 +6,7 @@ import signal
import sys import sys
# This is just for testing the process management. # This is just for testing the process management.
def filterfunc(n): def test(n):
n = int(n) n = int(n)
if n < 0: # raise an exception if n < 0: # raise an exception
raise Exception("test exception") raise Exception("test exception")

View File

@@ -61,8 +61,12 @@ setup(name='nilmrun',
long_description = "NILM Database Filter Runner", long_description = "NILM Database Filter Runner",
license = "Proprietary", license = "Proprietary",
author_email = 'jim@jtan.com', author_email = 'jim@jtan.com',
install_requires = [ 'nilmdb >= 1.8.0', install_requires = [ 'nilmdb >= 1.8.2',
'nilmtools >= 1.2.2', 'nilmtools >= 1.2.2',
'psutil >= 0.3.0',
'cherrypy >= 3.2',
'decorator',
'simplejson',
'numpy', 'numpy',
'scipy', 'scipy',
], ],

View File

@@ -1,3 +1,3 @@
test_client.py test_nilmrun.py
test_*.py test_*.py

View File

@@ -30,6 +30,7 @@ import textwrap
from testutil.helpers import * from testutil.helpers import *
testurl = "http://localhost:32181/" testurl = "http://localhost:32181/"
#testurl = "http://bucket.mit.edu/nilmrun/"
def setup_module(): def setup_module():
global test_server global test_server
@@ -49,10 +50,10 @@ class TestClient(object):
def wait_kill(self, client, pid, timeout = 1): def wait_kill(self, client, pid, timeout = 1):
time.sleep(timeout) time.sleep(timeout)
status = client.get("/process/status", { "pid": pid }) status = client.get("process/status", { "pid": pid })
if not status["alive"]: if not status["alive"]:
raise AssertionError("died before we could kill it") raise AssertionError("died before we could kill it")
status = client.post("/process/remove", { "pid": pid }) status = client.post("process/remove", { "pid": pid })
if status["alive"]: if status["alive"]:
raise AssertionError("didn't get killed") raise AssertionError("didn't get killed")
return status return status
@@ -61,180 +62,46 @@ class TestClient(object):
start = time.time() start = time.time()
status = None status = None
while (time.time() - start) < timeout: while (time.time() - start) < timeout:
status = client.get("/process/status", { "pid": pid }) status = client.get("process/status", { "pid": pid })
if status["alive"] == False: if status["alive"] == False:
break break
else: else:
raise AssertionError("process " + str(pid) + " didn't die in " + raise AssertionError("process " + str(pid) + " didn't die in " +
str(timeout) + " seconds: " + repr(status)) str(timeout) + " seconds: " + repr(status))
if remove: if remove:
status = client.post("/process/remove", { "pid": pid }) status = client.post("process/remove", { "pid": pid })
return status return status
def test_client_01_basic(self): def test_client_01_basic(self):
client = HTTPClient(baseurl = testurl) client = HTTPClient(baseurl = testurl)
version = client.get("/version") version = client.get("version")
eq_(distutils.version.LooseVersion(version), eq_(distutils.version.LooseVersion(version),
distutils.version.LooseVersion(nilmrun.__version__)) distutils.version.LooseVersion(nilmrun.__version__))
in_("This is NilmRun", client.get("/")) in_("This is NilmRun", client.get(""))
with assert_raises(ClientError): with assert_raises(ClientError):
client.get("/favicon.ico") client.get("favicon.ico")
def test_client_02_manager(self): def test_client_02_manager(self):
client = HTTPClient(baseurl = testurl) client = HTTPClient(baseurl = testurl)
eq_(client.get("/process/list"), []) eq_(client.get("process/list"), [])
with assert_raises(ClientError) as e: with assert_raises(ClientError) as e:
client.get("/process/status", { "pid": 12345 }) client.get("process/status", { "pid": 12345 })
in_("No such PID", str(e.exception)) in_("No such PID", str(e.exception))
with assert_raises(ClientError): with assert_raises(ClientError):
client.get("/process/remove", { "pid": 12345 }) client.get("process/remove", { "pid": 12345 })
in_("No such PID", str(e.exception)) in_("No such PID", str(e.exception))
def test_client_03_process_basic(self): def test_client_03_run_command(self):
client = HTTPClient(baseurl = testurl, post_json = True) client = HTTPClient(baseurl = testurl, post_json = True)
eq_(client.get("process/list"), [])
# start dummy filter
pid = client.post("/run/dummy", { "data": 30 })
eq_(client.get("/process/list"), [pid])
time.sleep(1)
# Verify that status looks OK
status = client.get("/process/status", { "pid": pid, "clear": True })
for x in [ "pid", "alive", "exitcode", "name",
"start_time", "parameters", "log" ]:
in_(x, status)
in_("dummy 0\ndummy 1\ndummy 2\ndummy 3\n", status["log"])
eq_(status["alive"], True)
eq_(status["exitcode"], None)
# Check that the log got cleared
status = client.get("/process/status", { "pid": pid })
nin_("dummy 0\ndummy 1\ndummy 2\ndummy 3\n", status["log"])
# See that it ended properly
status = self.wait_end(client, pid, remove = False)
in_("dummy 27\ndummy 28\ndummy 29\n", status["log"])
eq_(status["exitcode"], 0)
# Remove it
killstatus = client.post("/process/remove", { "pid": pid })
eq_(status, killstatus)
eq_(client.get("/process/list"), [])
with assert_raises(ClientError) as e:
client.post("/process/remove", { "pid": pid })
in_("No such PID", str(e.exception))
def test_client_04_process_terminate(self):
client = HTTPClient(baseurl = testurl, post_json = True)
# Trigger exception in filter
pid = client.post("/run/dummy", { "data": -1 })
time.sleep(0.5)
status = client.get("/process/status", { "pid": pid })
eq_(status["alive"], False)
eq_(status["exitcode"], 1)
in_("Exception: test exception", status["log"])
client.post("/process/remove", { "pid": pid })
# Kill a running filter by removing it early
newpid = client.post("/run/dummy", { "data": 50 })
ne_(newpid, pid)
time.sleep(0.5)
start = time.time()
status = client.post("/process/remove", { "pid": newpid })
elapsed = time.time() - start
# Should have died in slightly over 1 second
assert(0.5 < elapsed < 2)
eq_(status["alive"], False)
ne_(status["exitcode"], 0)
# No more
eq_(client.get("/process/list"), [])
# Try to remove a running filter that ignored SIGTERM
pid = client.post("/run/dummy", { "data": 0 })
start = time.time()
status = client.post("/process/remove", { "pid": pid })
elapsed = time.time() - start
# Should have died in slightly over 2 seconds
assert(1.5 < elapsed < 3)
eq_(status["alive"], False)
ne_(status["exitcode"], 0)
def test_client_05_trainola_simple(self):
client = HTTPClient(baseurl = testurl, post_json = True)
pid = client.post("/run/trainola", { "data": {} })
status = self.wait_end(client, pid, remove = False)
ne_(status["exitcode"], 0)
status = client.post("/process/remove", { "pid": pid })
@unittest.skip("needs a running nilmdb")
def test_client_06_trainola(self):
client = HTTPClient(baseurl = testurl, post_json = True)
data = { "url": "http://bucket.mit.edu/nilmdb",
"stream": "/sharon/prep-a",
"start": 1366111383280463,
"end": 1366126163457797,
"columns": [ { "name": "P1", "index": 0 },
{ "name": "Q1", "index": 1 },
{ "name": "P3", "index": 2 } ],
"exemplars": [
{ "name": "Boiler Pump ON",
"url": "http://bucket.mit.edu/nilmdb",
"stream": "/sharon/prep-a",
"start": 1366260494269078,
"end": 1366260608185031,
"columns": [ { "name": "P1", "index": 0 },
{ "name": "Q1", "index": 1 }
]
},
{ "name": "Boiler Pump OFF",
"url": "http://bucket.mit.edu/nilmdb",
"stream": "/sharon/prep-a",
"start": 1366260864215764,
"end": 1366260870882998,
"columns": [ { "name": "P1", "index": 0 },
{ "name": "Q1", "index": 1 }
]
}
]
}
# start trainola
pid = client.post("/run/trainola", { "data": data })
# wait for it to finish
for i in range(60):
time.sleep(1)
if i == 2:
status = client.get("/process/status", { "pid": pid,
"clear": True })
in_("Loading stream data", status['log'])
elif i == 3:
status = client.get("/process/status", { "pid": pid })
nin_("Loading stream data", status['log'])
else:
status = client.get("/process/status", { "pid": pid })
if status["alive"] == False:
break
else:
client.post("/process/remove", {"pid": pid })
raise AssertionError("took too long")
if i < 3:
raise AssertionError("too fast?")
def test_client_07_run_command(self):
client = HTTPClient(baseurl = testurl, post_json = True)
eq_(client.get("/process/list"), [])
def do(argv, kill): def do(argv, kill):
pid = client.post("/run/command", { "argv": argv } ) pid = client.post("run/command", { "argv": argv } )
eq_(client.get("/process/list"), [pid]) eq_(client.get("process/list"), [pid])
if kill: if kill:
return self.wait_kill(client, pid) return self.wait_kill(client, pid)
return self.wait_end(client, pid) return self.wait_end(client, pid)
@@ -250,26 +117,153 @@ class TestClient(object):
eq_("3\n", status["log"]) eq_("3\n", status["log"])
# Missing command # Missing command
status = do(["/no-such-command-blah-blah"], False) with assert_raises(ClientError) as e:
ne_(status["exitcode"], 0) do(["/no-such-command-blah-blah"], False)
in_("No such file or directory", str(e.exception))
# Kill a slow command # Kill a slow command
status = do(["sleep", "60"], True) status = do(["sleep", "60"], True)
ne_(status["exitcode"], 0) ne_(status["exitcode"], 0)
def test_client_08_run_code(self): def _run_testfilter(self, client, args):
code = textwrap.dedent("""
import nilmrun.testfilter
import simplejson as json
import sys
nilmrun.testfilter.test(json.loads(sys.argv[1]))
""")
jsonargs = json.dumps(args)
return client.post("run/code", { "code": code, "args": [ jsonargs ] })
def test_client_04_process_basic(self):
client = HTTPClient(baseurl = testurl, post_json = True) client = HTTPClient(baseurl = testurl, post_json = True)
eq_(client.get("/process/list"), [])
# start dummy filter
pid = self._run_testfilter(client, 30)
eq_(client.get("process/list"), [pid])
time.sleep(1)
# Verify that status looks OK
status = client.get("process/status", { "pid": pid, "clear": True })
for x in [ "pid", "alive", "exitcode", "name", "start_time", "log" ]:
in_(x, status)
in_("dummy 0\ndummy 1\ndummy 2\ndummy 3\n", status["log"])
eq_(status["alive"], True)
eq_(status["exitcode"], None)
# Check that the log got cleared
status = client.get("process/status", { "pid": pid })
nin_("dummy 0\ndummy 1\ndummy 2\ndummy 3\n", status["log"])
# See that it ended properly
status = self.wait_end(client, pid, remove = False)
in_("dummy 27\ndummy 28\ndummy 29\n", status["log"])
eq_(status["exitcode"], 0)
# Remove it
killstatus = client.post("process/remove", { "pid": pid })
eq_(status, killstatus)
eq_(client.get("process/list"), [])
with assert_raises(ClientError) as e:
client.post("process/remove", { "pid": pid })
in_("No such PID", str(e.exception))
def test_client_05_process_terminate(self):
client = HTTPClient(baseurl = testurl, post_json = True)
# Trigger exception in filter
pid = self._run_testfilter(client, -1)
time.sleep(0.5)
status = client.get("process/status", { "pid": pid })
eq_(status["alive"], False)
eq_(status["exitcode"], 1)
in_("Exception: test exception", status["log"])
client.post("process/remove", { "pid": pid })
# Kill a running filter by removing it early
newpid = self._run_testfilter(client, 50)
ne_(newpid, pid)
time.sleep(0.5)
start = time.time()
status = client.post("process/remove", { "pid": newpid })
elapsed = time.time() - start
# Should have died in slightly over 1 second
assert(0.5 < elapsed < 2)
eq_(status["alive"], False)
ne_(status["exitcode"], 0)
# No more
eq_(client.get("process/list"), [])
# Try to remove a running filter that ignored SIGTERM
pid = self._run_testfilter(client, 0)
start = time.time()
status = client.post("process/remove", { "pid": pid })
elapsed = time.time() - start
# Should have died in slightly over 2 seconds
assert(1.5 < elapsed < 3)
eq_(status["alive"], False)
ne_(status["exitcode"], 0)
@unittest.skip("needs a running nilmdb; trainola moved to nilmtools")
def test_client_06_trainola(self):
client = HTTPClient(baseurl = testurl, post_json = True)
data = { "url": "http://bucket.mit.edu/nilmdb",
"dest_stream": "/sharon/prep-a-matches",
"stream": "/sharon/prep-a",
"start": 1366111383280463,
"end": 1366126163457797,
"columns": [ { "name": "P1", "index": 0 },
{ "name": "Q1", "index": 1 },
{ "name": "P3", "index": 2 } ],
"exemplars": [
{ "name": "Boiler Pump ON",
"url": "http://bucket.mit.edu/nilmdb",
"stream": "/sharon/prep-a",
"start": 1366260494269078,
"end": 1366260608185031,
"dest_column": 0,
"columns": [ { "name": "P1", "index": 0 },
{ "name": "Q1", "index": 1 }
]
},
{ "name": "Boiler Pump OFF",
"url": "http://bucket.mit.edu/nilmdb",
"stream": "/sharon/prep-a",
"start": 1366260864215764,
"end": 1366260870882998,
"dest_column": 1,
"columns": [ { "name": "P1", "index": 0 },
{ "name": "Q1", "index": 1 }
]
}
]
}
pid = client.post("run/code", { "code": "import nilmtools.trainola\n" +
"nilmtools.trainola.main()",
"args": [ data ] })
while True:
status = client.get("process/status", { "pid": pid, "clear": 1 })
sys.stdout.write(status["log"])
sys.stdout.flush()
if status["alive"] == False:
break
status = client.post("process/remove", { "pid": pid })
os._exit(int(status["exitcode"]))
def test_client_07_run_code(self):
client = HTTPClient(baseurl = testurl, post_json = True)
eq_(client.get("process/list"), [])
def do(code, args, kill): def do(code, args, kill):
pid = client.post("/run/code", { "code": code, "args": args } ) pid = client.post("run/code", { "code": code, "args": args } )
eq_(client.get("/process/list"), [pid]) eq_(client.get("process/list"), [pid])
if kill: if kill:
return self.wait_kill(client, pid) return self.wait_kill(client, pid)
return self.wait_end(client, pid) return self.wait_end(client, pid)
# basic code snippet # basic code snippet
code=textwrap.dedent(""" code = textwrap.dedent("""
print 'hello' print 'hello'
def foo(arg): def foo(arg):
print 'world' print 'world'
@@ -279,7 +273,7 @@ class TestClient(object):
eq_(status["exitcode"], 0) eq_(status["exitcode"], 0)
# compile error # compile error
code=textwrap.dedent(""" code = textwrap.dedent("""
def foo(arg: def foo(arg:
print 'hello' print 'hello'
""") """)
@@ -288,31 +282,36 @@ class TestClient(object):
eq_(status["exitcode"], 1) eq_(status["exitcode"], 1)
# traceback in user code should be formatted nicely # traceback in user code should be formatted nicely
code=textwrap.dedent(""" code = textwrap.dedent("""
def foo(arg): def foo(arg):
raise Exception(arg) raise Exception(arg)
foo(123) foo(123)
""") """)
status = do(code, [], False) status = do(code, [], False)
cleaned_log = re.sub('File "[^"]*",', 'File "",', status["log"])
eq_('Traceback (most recent call last):\n' + eq_('Traceback (most recent call last):\n' +
' File "<user-code>", line 4, in <module>\n' + ' File "", line 4, in <module>\n' +
' foo(123)\n' + ' foo(123)\n' +
' File "<user-code>", line 3, in foo\n' + ' File "", line 3, in foo\n' +
' raise Exception(arg)\n' + ' raise Exception(arg)\n' +
'Exception: 123\n', status["log"]) 'Exception: 123\n', cleaned_log)
eq_(status["exitcode"], 1) eq_(status["exitcode"], 1)
# argument handling (strings come in as unicode) # argument handling (strings come in as unicode)
code=textwrap.dedent(""" code = textwrap.dedent("""
import sys import sys
print sys.argv[1].encode('ascii'), sys.argv[2] print sys.argv[1].encode('ascii'), sys.argv[2]
sys.exit(0) # also test raising SystemExit
""") """)
status = do(code, ["hello", 123], False) with assert_raises(ClientError) as e:
do(code, ["hello", 123], False)
in_("400 Bad Request", str(e.exception))
status = do(code, ["hello", "123"], False)
eq_(status["log"], "hello 123\n") eq_(status["log"], "hello 123\n")
eq_(status["exitcode"], 0) eq_(status["exitcode"], 0)
# try killing a long-running process # try killing a long-running process
code=textwrap.dedent(""" code = textwrap.dedent("""
import time import time
print 'hello' print 'hello'
time.sleep(60) time.sleep(60)
@@ -321,3 +320,41 @@ class TestClient(object):
status = do(code, [], True) status = do(code, [], True)
eq_(status["log"], "hello\n") eq_(status["log"], "hello\n")
ne_(status["exitcode"], 0) ne_(status["exitcode"], 0)
def test_client_08_bad_types(self):
client = HTTPClient(baseurl = testurl, post_json = True)
with assert_raises(ClientError) as e:
client.post("run/code", { "code": "asdf", "args": "qwer" })
in_("must be a list", str(e.exception))
with assert_raises(ClientError) as e:
client.post("run/command", { "argv": "asdf" })
in_("must be a list", str(e.exception))
def test_client_00_info(self):
client = HTTPClient(baseurl = testurl, post_json = True)
# start some processes
a = client.post("run/command", { "argv": ["sleep","60"] } )
b = client.post("run/command", { "argv": ["sh","-c","sleep 2;true"] } )
c = client.post("run/command", { "argv": ["sh","-c","burnP5;true"] } )
d = client.post("run/command", { "argv": ["burnP5" ] } )
info = client.get("process/info")
eq_(info["pids"][a]["procs"], 1)
eq_(info["pids"][b]["procs"], 2)
eq_(info["pids"][c]["procs"], 2)
eq_(info["pids"][d]["procs"], 1)
eq_(info["total"]["procs"], 6)
lt_(info["pids"][a]["cpu_percent"], 50)
lt_(20, info["pids"][c]["cpu_percent"])
lt_(80, info["system"]["cpu_percent"])
time.sleep(2)
info = client.get("process/info")
eq_(info["pids"][b]["procs"], 0)
# kill all processes
for pid in client.get("process/list"):
client.post("process/remove", { "pid": pid })