tools: modernize nfd-status-http-server and improve error handling

Change-Id: I5715c35f79d7dcd8dbefed328ebb625e9150a846
diff --git a/tools/nfd-status-http-server.py b/tools/nfd-status-http-server.py
index 7284448..ee426cd 100755
--- a/tools/nfd-status-http-server.py
+++ b/tools/nfd-status-http-server.py
@@ -1,8 +1,6 @@
 #!/usr/bin/env python3
-# -*- Mode: python; py-indent-offset: 4; indent-tabs-mode: nil; coding: utf-8; -*-
-
 """
-Copyright (c) 2014-2018,  Regents of the University of California,
+Copyright (c) 2014-2023,  Regents of the University of California,
                           Arizona Board of Regents,
                           Colorado State University,
                           University Pierre & Marie Curie, Sorbonne University,
@@ -25,45 +23,52 @@
 NFD, e.g., in COPYING.md file.  If not, see <http://www.gnu.org/licenses/>.
 """
 
-from http.server import HTTPServer, SimpleHTTPRequestHandler
-from socketserver import ThreadingMixIn
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
 from urllib.parse import urlsplit
 import argparse, ipaddress, os, socket, subprocess
 
 
 class NfdStatusHandler(SimpleHTTPRequestHandler):
-    """ The handler class to handle requests """
+    """The handler class to handle HTTP requests."""
+
     def do_GET(self):
         path = urlsplit(self.path).path
         if path == "/":
-            self.__serveReport()
-        elif path == "/robots.txt" and self.server.allowRobots:
+            self.__serve_report()
+        elif path == "/robots.txt" and self.server.allow_robots:
             self.send_error(404)
         else:
             super().do_GET()
 
-    def __serveReport(self):
-        """ Obtain XML-formatted NFD status report and send it back as response body """
+    def __serve_report(self):
+        """Obtain XML-formatted NFD status report and send it back as response body."""
         try:
-            # enable universal_newlines to get output as string rather than byte sequence
-            output = subprocess.check_output(["nfdc", "status", "report", "xml"], universal_newlines=True)
+            proc = subprocess.run(
+                ["nfdc", "status", "report", "xml"], capture_output=True, check=True, text=True, timeout=10
+            )
+            output = proc.stdout
         except OSError as err:
-            super().log_message("error invoking nfdc: {}".format(err))
+            super().log_message(f"error invoking nfdc: {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))
+        except subprocess.SubprocessError as err:
+            super().log_message(f"error invoking nfdc: {err}")
+            self.send_error(504, "Cannot connect to NFD")
         else:
             # add stylesheet processing instruction after the XML document type declaration
             # (yes, this is a ugly hack)
-            pos = output.index(">") + 1
-            xml = output[:pos]\
-                + '<?xml-stylesheet type="text/xsl" href="nfd-status.xsl"?>'\
-                + output[pos:]
-            self.send_response(200)
-            self.send_header("Content-Type", "text/xml; charset=UTF-8")
-            self.end_headers()
-            self.wfile.write(xml.encode())
+            if (pos := output.find(">") + 1) != 0:
+                xml = (
+                    output[:pos]
+                    + '<?xml-stylesheet type="text/xsl" href="nfd-status.xsl"?>'
+                    + output[pos:]
+                )
+                self.send_response(200)
+                self.send_header("Content-Type", "text/xml; charset=UTF-8")
+                self.end_headers()
+                self.wfile.write(xml.encode())
+            else:
+                super().log_message(f"malformed nfdc output: {output}")
+                self.send_error(500)
 
     # override
     def log_message(self, *args):
@@ -71,42 +76,41 @@
             super().log_message(*args)
 
 
-class ThreadingHttpServer(ThreadingMixIn, HTTPServer):
-    """ Handle requests using threads """
-    def __init__(self, bindAddr, port, handler, allowRobots=False, verbose=False):
+class NfdStatusHttpServer(ThreadingHTTPServer):
+    def __init__(self, addr, port, *, robots=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:
+        if addr.version == 6:
             self.address_family = socket.AF_INET6
-        self.allowRobots = allowRobots
+        self.allow_robots = robots
         self.verbose = verbose
-        super().__init__((str(bindAddr), port), handler)
+        super().__init__((str(addr), port), NfdStatusHandler)
 
 
 def main():
-    def ipAddress(arg):
-        """ Validate IP address """
+    def ip_address(arg, /):
+        """Validate IP address."""
         try:
             value = ipaddress.ip_address(arg)
         except ValueError:
-            raise argparse.ArgumentTypeError("{!r} is not a valid IP address".format(arg))
+            raise argparse.ArgumentTypeError(f"{arg!r} is not a valid IP address")
         return value
 
-    def portNumber(arg):
-        """ Validate port number """
+    def port_number(arg, /):
+        """Validate port number."""
         try:
             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))
+            raise argparse.ArgumentTypeError(f"{arg!r} is not a valid port number")
         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",
+    parser.add_argument("-a", "--address", default="127.0.0.1", type=ip_address, metavar="ADDR",
                         help="bind to this IP address (default: %(default)s)")
-    parser.add_argument("-p", "--port", default=8080, type=portNumber,
+    parser.add_argument("-p", "--port", default=8080, type=port_number,
                         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)")
@@ -118,20 +122,17 @@
 
     os.chdir(args.workdir)
 
-    httpd = ThreadingHttpServer(args.address, args.port, NfdStatusHandler,
-                                allowRobots=args.robots, verbose=args.verbose)
+    with NfdStatusHttpServer(args.address, args.port, robots=args.robots, verbose=args.verbose) as httpd:
+        if httpd.address_family == socket.AF_INET6:
+            url = "http://[{}]:{}"
+        else:
+            url = "http://{}:{}"
+        print("Server started at", url.format(*httpd.server_address))
 
-    if httpd.address_family == socket.AF_INET6:
-        url = "http://[{}]:{}"
-    else:
-        url = "http://{}:{}"
-    print("Server started at", url.format(*httpd.server_address))
-
-    try:
-        httpd.serve_forever()
-    except KeyboardInterrupt:
-        pass
-    httpd.server_close()
+        try:
+            httpd.serve_forever()
+        except KeyboardInterrupt:
+            pass
 
 
 if __name__ == "__main__":