tools: port nfd-status-http-server to python3

Also cleanup tools/wscript

Change-Id: Iedb9d0ef3c63ad5ef7350381f0b5dfe1681f82b0
diff --git a/tools/nfd-status-http-server.py b/tools/nfd-status-http-server.py
index 7d5f1b8..5f2c891 100755
--- a/tools/nfd-status-http-server.py
+++ b/tools/nfd-status-http-server.py
@@ -1,8 +1,8 @@
-#!/usr/bin/env python2.7
+#!/usr/bin/env python3
 # -*- Mode: python; py-indent-offset: 4; indent-tabs-mode: nil; coding: utf-8; -*-
 
 """
-Copyright (c) 2014-2016,  Regents of the University of California,
+Copyright (c) 2014-2018,  Regents of the University of California,
                           Arizona Board of Regents,
                           Colorado State University,
                           University Pierre & Marie Curie, Sorbonne University,
@@ -25,165 +25,112 @@
 NFD, e.g., in COPYING.md file.  If not, see <http://www.gnu.org/licenses/>.
 """
 
-from BaseHTTPServer import HTTPServer
-from SimpleHTTPServer import SimpleHTTPRequestHandler
-from SocketServer import ThreadingMixIn
-import sys
-import subprocess
-import urlparse
-import logging
-import argparse
-import socket
-import os
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from socketserver import ThreadingMixIn
+import argparse, ipaddress, os, socket, subprocess
 
 
-class StatusHandler(SimpleHTTPRequestHandler):
-    """ The handler class to handle requests."""
+class NfdStatusHandler(SimpleHTTPRequestHandler):
+    """ The handler class to handle requests """
     def do_GET(self):
-        # get the url info to decide how to respond
-        parsedPath = urlparse.urlparse(self.path)
-        if parsedPath.path == "/":
-            # get current nfd status, and use it as result message
-            (httpCode, contentType, body) = self.getNfdStatus()
-            self.send_response(httpCode)
-            self.send_header("Content-Type", contentType)
-            self.end_headers()
-            self.wfile.write(body)
-        elif parsedPath.path == "/robots.txt" and self.server.robots == True:
-            self.send_response(200)
-            self.send_header("Content-Type", "text/plain")
-            self.end_headers()
+        if self.path == "/":
+            self.__serveReport()
+        elif self.path == "/robots.txt" and self.server.allowRobots:
+            self.send_error(404)
         else:
-            SimpleHTTPRequestHandler.do_GET(self)
+            super().do_GET()
 
-    def log_message(self, format, *args):
-        if self.server.verbose:
-            logging.info("%s - %s\n" % (self.address_string(), format % args))
-
-    def makeErrorResponseHtml(self, text):
-        return '<!DOCTYPE html><title>NFD status</title><p>%s</p>' % text
-
-    def getNfdStatus(self):
-        """ Obtain XML-formatted NFD status report """
+    def __serveReport(self):
+        """ Obtain XML-formatted NFD status report and send it back as response body """
         try:
-            sp = subprocess.Popen(['nfdc', 'status', 'report', 'xml'], stdout=subprocess.PIPE, close_fds=True)
-            output = sp.communicate()[0]
-        except OSError as e:
-            self.log_message('error invoking nfdc: %s', e)
-            html = self.makeErrorResponseHtml('Internal error')
-            return (500, "text/html; charset=UTF-8", html)
-
-        if sp.returncode == 0:
+            # enable universal_newlines to get output as string rather than byte sequence
+            output = subprocess.check_output(["nfdc", "status", "report", "xml"], universal_newlines=True)
+        except OSError as err:
+            super().log_message("error invoking nfdc: {}".format(err))
+            self.send_error(500)
+        except subprocess.CalledProcessError as err:
+            super().log_message("error invoking nfdc: command exited with status {}".format(err.returncode))
+            self.send_error(504, "Cannot connect to NFD (code {})".format(err.returncode))
+        else:
             # add stylesheet processing instruction after the XML document type declaration
-            pos = output.index('>') + 1
+            # (yes, this is a ugly hack)
+            pos = output.index(">") + 1
             xml = output[:pos]\
                 + '<?xml-stylesheet type="text/xsl" href="nfd-status.xsl"?>'\
                 + output[pos:]
-            return (200, 'text/xml; charset=UTF-8', xml)
-        else:
-            html = self.makeErrorResponseHtml('Cannot connect to NFD, Code = %d' % sp.returncode)
-            return (504, "text/html; charset=UTF-8", html)
+            self.send_response(200)
+            self.send_header("Content-Type", "text/xml; charset=UTF-8")
+            self.end_headers()
+            self.wfile.write(xml.encode())
 
-class ThreadHttpServer(ThreadingMixIn, HTTPServer):
+    # override
+    def log_message(self, *args):
+        if self.server.verbose:
+            super().log_message(*args)
+
+
+class ThreadingHttpServer(ThreadingMixIn, HTTPServer):
     """ Handle requests using threads """
-    def __init__(self, server, handler, verbose=False, robots=False):
-        serverAddr = server[0]
-        # socket.AF_UNSPEC is not supported, check whether it is v6 or v4
-        ipType = self.getIpType(serverAddr)
-        if ipType == socket.AF_INET6:
+    def __init__(self, bindAddr, port, handler, allowRobots=False, verbose=False):
+        # socketserver.BaseServer defaults to AF_INET even if you provide an IPv6 address
+        # see https://bugs.python.org/issue20215 and https://bugs.python.org/issue24209
+        if bindAddr.version == 6:
             self.address_family = socket.AF_INET6
-        elif ipType == socket.AF_INET:
-            self.address_family == socket.AF_INET
-        else:
-            logging.error("The input IP address is neither IPv6 nor IPv4")
-            sys.exit(2)
-
-        try:
-            HTTPServer.__init__(self, server, handler)
-        except Exception as e:
-            logging.error(str(e))
-            sys.exit(2)
+        self.allowRobots = allowRobots
         self.verbose = verbose
-        self.robots = robots
+        super().__init__((str(bindAddr), port), handler)
 
-    def getIpType(self, ipAddr):
-        """ Get ipAddr's address type """
-        # if ipAddr is an IPv6 addr, return AF_INET6
+
+def main():
+    def ipAddress(arg):
+        """ Validate IP address """
         try:
-            socket.inet_pton(socket.AF_INET6, ipAddr)
-            return socket.AF_INET6
-        except socket.error:
-            pass
-        # if ipAddr is an IPv4 addr return AF_INET, if not, return None
+            value = ipaddress.ip_address(arg)
+        except ValueError:
+            raise argparse.ArgumentTypeError("{!r} is not a valid IP address".format(arg))
+        return value
+
+    def portNumber(arg):
+        """ Validate port number """
         try:
-            socket.inet_pton(socket.AF_INET, ipAddr)
-            return socket.AF_INET
-        except socket.error:
-            return None
+            value = int(arg)
+        except ValueError:
+            value = -1
+        if value < 0 or value > 65535:
+            raise argparse.ArgumentTypeError("{!r} is not a valid port number".format(arg))
+        return value
 
+    parser = argparse.ArgumentParser(description="Serves NFD status page via HTTP")
+    parser.add_argument("-V", "--version", action="version", version="@VERSION@")
+    parser.add_argument("-a", "--address", default="127.0.0.1", type=ipAddress, metavar="ADDR",
+                        help="bind to this IP address (default: %(default)s)")
+    parser.add_argument("-p", "--port", default=8080, type=portNumber,
+                        help="bind to this port number (default: %(default)s)")
+    parser.add_argument("-f", "--workdir", default="@DATAROOTDIR@/ndn", metavar="DIR",
+                        help="server's working directory (default: %(default)s)")
+    parser.add_argument("-r", "--robots", action="store_true",
+                        help="allow crawlers and other HTTP bots")
+    parser.add_argument("-v", "--verbose", action="store_true",
+                        help="turn on verbose logging")
+    args = parser.parse_args()
 
-# main function to start
-def httpServer():
-    parser = argparse.ArgumentParser()
-    parser.add_argument("-p", type=int, metavar="port number",
-                        help="Specify the HTTP server port number, default is 8080.",
-                        dest="port", default=8080)
-    # if address is not specified, use 127.0.0.1
-    parser.add_argument("-a", default="127.0.0.1", metavar="IP address", dest="addr",
-                        help="Specify the HTTP server IP address.")
-    parser.add_argument("-r", default=False, dest="robots", action="store_true",
-                        help="Enable HTTP robots to crawl; disabled by default.")
-    parser.add_argument("-f", default="@DATAROOTDIR@/ndn", metavar="Server Directory", dest="serverDir",
-                        help="Specify the working directory of nfd-status-http-server, default is @DATAROOTDIR@/ndn.")
-    parser.add_argument("-v", default=False, dest="verbose", action="store_true",
-                        help="Verbose mode.")
-    parser.add_argument("--version", default=False, dest="version", action="store_true",
-                        help="Show version and exit")
+    os.chdir(args.workdir)
 
-    args = vars(parser.parse_args())
+    httpd = ThreadingHttpServer(args.address, args.port, NfdStatusHandler,
+                                allowRobots=args.robots, verbose=args.verbose)
 
-    if args['version']:
-        print "@VERSION@"
-        return
-
-    localPort = args["port"]
-    localAddr = args["addr"]
-    verbose = args["verbose"]
-    robots = args["robots"]
-    serverDirectory = args["serverDir"]
-
-    os.chdir(serverDirectory)
-
-    # setting log message format
-    logging.basicConfig(format='%(asctime)s [%(levelname)s] %(message)s',
-                        level=logging.INFO)
-
-    # if port is invalid, exit
-    if localPort <= 0 or localPort > 65535:
-        logging.error("Specified port number is invalid")
-        sys.exit(2)
-
-    httpd = ThreadHttpServer((localAddr, localPort), StatusHandler,
-                             verbose, robots)
-    httpServerAddr = ""
     if httpd.address_family == socket.AF_INET6:
-        httpServerAddr = "http://[%s]:%s" % (httpd.server_address[0],
-                                             httpd.server_address[1])
+        url = "http://[{}]:{}"
     else:
-        httpServerAddr = "http://%s:%s" % (httpd.server_address[0],
-                                           httpd.server_address[1])
-
-    logging.info("Server started - at %s" % httpServerAddr)
+        url = "http://{}:{}"
+    print("Server started at", url.format(*httpd.server_address))
 
     try:
         httpd.serve_forever()
     except KeyboardInterrupt:
         pass
-
     httpd.server_close()
 
-    logging.info("Server stopped")
 
-
-if __name__ == '__main__':
-    httpServer()
+if __name__ == "__main__":
+    main()
diff --git a/tools/wscript b/tools/wscript
index f3dd5c9..de4cb78 100644
--- a/tools/wscript
+++ b/tools/wscript
@@ -4,68 +4,56 @@
 
 top = '..'
 
-TOOLS_DEPENDENCY = 'core-objects NDN_CXX BOOST LIBRESOLV'
-
 def build(bld):
-    # single object tools
-    # tools/example-tool.cpp should a self-contained tool with a main function
-    # and it's built into build/bin/example-tool.
+    TOOLS_DEPENDENCY = 'core-objects NDN_CXX BOOST LIBRESOLV'
+
+    # Single object tools:
+    # tools/example-tool.cpp is a self-contained tool with a main() function
+    # and is built as build/bin/example-tool.
     # These tools cannot be unit-tested.
     for i in bld.path.ant_glob(['*.cpp']):
-        name = str(i)[:-len(".cpp")]
-        bld(features='cxx cxxprogram',
-            target="../bin/%s" % name,
-            source=[i],
-            use=TOOLS_DEPENDENCY
-            )
+        name = str(i)[:-len('.cpp')]
+        bld.program(target='../bin/%s' % name,
+                    source=[i],
+                    use=TOOLS_DEPENDENCY)
 
-
-    # sub-directory tools
-    # tools/example-tool/**/*.cpp is compiled and linked into build/bin/example-tool
-    # tools/example-tool/main.cpp must exist and it should contain the main function.
+    # Sub-directory tools:
+    # tools/example-tool/**/*.cpp is compiled and linked into build/bin/example-tool.
+    # tools/example-tool/main.cpp must exist and must contain the main() function.
     # All other objects are collected into 'tools-objects' and can be unit-tested.
     testableObjects = []
     for name in bld.path.ant_glob(['*'], dir=True, src=False):
         mainFile = bld.path.find_node(['%s/main.cpp' % name])
         if mainFile is None:
-          continue # not a C++ tool
+            continue # not a C++ tool
         srcFiles = bld.path.ant_glob(['%s/**/*.cpp' % name], excl=['%s/main.cpp' % name])
-        if len(srcFiles) > 0:
+        if srcFiles:
             srcObjects = 'tools-%s-objects' % name
-            bld(features='cxx',
-                name=srcObjects,
-                source=srcFiles,
-                use=TOOLS_DEPENDENCY,
-                includes='%s' % name,
-                )
+            bld.objects(target=srcObjects,
+                        source=srcFiles,
+                        use=TOOLS_DEPENDENCY,
+                        includes='%s' % name)
             testableObjects.append(srcObjects)
-
-            bld(features='cxx cxxprogram',
-                target="../bin/%s" % name,
-                source=[mainFile],
-                use=TOOLS_DEPENDENCY + ' ' + srcObjects,
-                includes='%s' % name,
-                )
         else:
-            bld(features='cxx cxxprogram',
-                target="../bin/%s" % name,
-                source=[mainFile],
-                use=TOOLS_DEPENDENCY,
-                includes='%s' % name,
-                )
+            srcObjects = ''
 
-    bld(name='tools-objects',
+        bld.program(target='../bin/%s' % name,
+                    source=[mainFile],
+                    use=TOOLS_DEPENDENCY + ' ' + srcObjects,
+                    includes='%s' % name)
+
+    bld(target='tools-objects',
         export_includes='.',
         use=testableObjects)
 
-    bld(features="subst",
-        source=bld.path.ant_glob(['*.sh', '*.py']),
-        target=['../bin/%s' % node.change_ext('')
-                for node in bld.path.ant_glob(['*.sh', '*.py'])],
-        install_path="${BINDIR}",
+    scripts = bld.path.ant_glob(['*.sh', '*.py'])
+    bld(features='subst',
+        name='scripts',
+        target=['../bin/%s' % node.change_ext('') for node in scripts],
+        source=scripts,
+        install_path='${BINDIR}',
         chmod=Utils.O755,
-        VERSION=Context.g_module.VERSION
-       )
+        VERSION=Context.g_module.VERSION)
 
-    bld.install_files("${DATAROOTDIR}/ndn",
+    bld.install_files('${DATAROOTDIR}/ndn',
                       bld.path.ant_glob('nfd-status-http-server-files/*'))