From 198db6f0003935e5f900719fa0b848924b8921bb Mon Sep 17 00:00:00 2001 From: Camil Staps Date: Fri, 4 Mar 2016 18:33:12 +0100 Subject: Project one until step 8 in the readme --- .../proj1_s4498062/content/test/no-index/.gitkeep | 0 project1/proj1_s4498062/webhttp/__init__.pyc | Bin 499 -> 0 bytes project1/proj1_s4498062/webhttp/composer.py | 24 +++- project1/proj1_s4498062/webhttp/message.py | 75 ++++++++-- project1/proj1_s4498062/webhttp/message.pyc | Bin 3726 -> 0 bytes project1/proj1_s4498062/webhttp/parser.py | 20 +-- project1/proj1_s4498062/webhttp/parser.pyc | Bin 2717 -> 0 bytes project1/proj1_s4498062/webhttp/regexes.py | 151 +++++++++++++++++++++ project1/proj1_s4498062/webhttp/resource.py | 6 +- project1/proj1_s4498062/webhttp/server.py | 49 +++++-- project1/proj1_s4498062/webhttp/server.pyc | Bin 2794 -> 0 bytes project1/proj1_s4498062/webhttp/weblogging.py | 9 ++ project1/proj1_s4498062/webserver.py | 33 ++++- project1/proj1_s4498062/webtests.py | 39 ++++-- 14 files changed, 351 insertions(+), 55 deletions(-) create mode 100644 project1/proj1_s4498062/content/test/no-index/.gitkeep delete mode 100644 project1/proj1_s4498062/webhttp/__init__.pyc delete mode 100644 project1/proj1_s4498062/webhttp/message.pyc delete mode 100644 project1/proj1_s4498062/webhttp/parser.pyc create mode 100644 project1/proj1_s4498062/webhttp/regexes.py delete mode 100644 project1/proj1_s4498062/webhttp/server.pyc create mode 100644 project1/proj1_s4498062/webhttp/weblogging.py (limited to 'project1') diff --git a/project1/proj1_s4498062/content/test/no-index/.gitkeep b/project1/proj1_s4498062/content/test/no-index/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/project1/proj1_s4498062/webhttp/__init__.pyc b/project1/proj1_s4498062/webhttp/__init__.pyc deleted file mode 100644 index d86ce3e..0000000 Binary files a/project1/proj1_s4498062/webhttp/__init__.pyc and /dev/null differ diff --git a/project1/proj1_s4498062/webhttp/composer.py b/project1/proj1_s4498062/webhttp/composer.py index dfac7a1..5123d31 100644 --- a/project1/proj1_s4498062/webhttp/composer.py +++ b/project1/proj1_s4498062/webhttp/composer.py @@ -7,8 +7,8 @@ HTTP requests from a client. import time import webhttp.message -import webhttp.resource - +from webhttp.resource import Resource, FileExistError, FileAccessError +import webhttp.weblogging as logging class ResponseComposer: """Class that composes a HTTP response to a HTTP request""" @@ -34,10 +34,22 @@ class ResponseComposer: response = webhttp.message.Response() # Stub code - response.code = 200 - response.set_header("Content-Length", 4) - response.set_header("Connection", "close") - response.body = "Test" + try: + resource = Resource(request.uri) + response.code = 200 + response.set_header('Content-Length', resource.get_content_length()) + response.set_header('Connection', 'close') + response.body = resource.get_content() + except FileExistError: + response.code = 404 + response.set_header("Content-Length", 9) + response.set_header("Connection", "close") + response.body = "Not found" + except FileAccessError: + response.code = 403 + response.set_header("Content-Length", 13) + response.set_header("Connection", "close") + response.body = "Access denied" return response diff --git a/project1/proj1_s4498062/webhttp/message.py b/project1/proj1_s4498062/webhttp/message.py index fb47f88..2dc4240 100644 --- a/project1/proj1_s4498062/webhttp/message.py +++ b/project1/proj1_s4498062/webhttp/message.py @@ -3,22 +3,29 @@ This modules contains classes for representing HTTP responses and requests. """ +import re + +import regexes as r +import weblogging as logging + reasondict = { # Dictionary for code reasons # Format: code : "Reason" - 500 : "Internal Server Error" + 200 : 'OK', + 403 : 'Forbidden', + 404 : 'Not Found', + 500 : 'Internal Server Error' } class Message(object): - """Class that stores a HTTP Message""" + """Class that stores an HTTP Message""" def __init__(self): """Initialize the Message""" self.version = "HTTP/1.1" - self.startline = "" self.body = "" - self.headerdict = dict() + self.headers = dict() def set_header(self, name, value): """Add a header and its value @@ -27,7 +34,7 @@ class Message(object): name (str): name of header value (str): value of header """ - self.headerdict[name] = value + self.headers[name] = str(value) def get_header(self, name): """Get the value of a header @@ -38,29 +45,56 @@ class Message(object): Returns: str: value of header, empty if header does not exist """ - if name in self.headerdict: - return self.headerdict[name] + if name in self.headers: + return self.headers[name] else: return "" - + + def parse_headers(self, msg): + for name, value in re.findall(r.MessageHeader, msg): + self.set_header(name, value.strip()) + logging.debug("%s: %s" % (name, value.strip())) + + def startline(self): + return '' + def __str__(self): """Convert the Message to a string Returns: str: representation the can be sent over socket """ - message = "" - return message + msg = '' + msg += '%s\r\n' % self.startline() + msg += '\r\n'.join([k + ": " + v for k, v in self.headers.iteritems()]) + msg += '\r\n\r\n' + msg += self.body + return msg + + def __eq__(self, other): + return self.__dict__ == other.__dict__ class Request(Message): - """Class that stores a HTTP request""" + """Class that stores an HTTP request""" def __init__(self): """Initialize the Request""" super(Request, self).__init__() self.method = "" self.uri = "" + + def parse(self, msg): + [reqline, rest] = msg.split('\r\n', 1) + reqline = re.match(r.RequestLine, reqline + '\r\n') + self.method, self.uri, self.version = reqline.groups() + + [headers, body] = rest.split('\r\n\r\n', 1) + self.parse_headers(headers) + self.body = body + + def startline(self): + return "%s %s %s" % (self.method, self.uri, self.version) def __str__(self): """Convert the Request to a string @@ -68,17 +102,28 @@ class Request(Message): Returns: str: representation the can be sent over socket """ - self.startline = "" return super(Request, self).__str__() class Response(Message): - """Class that stores a HTTP Response""" + """Class that stores an HTTP Response""" def __init__(self): """Initialize the Response""" super(Response, self).__init__() - self.code = 500 + + def parse(self, msg): + [respline, rest] = msg.split('\r\n', 1) + respline = re.match(r.StatusLine, respline + '\r\n') + self.version = respline.group(1) + self.code = int(respline.group(2)) + + [headers, body] = rest.split('\r\n\r\n', 1) + self.parse_headers(headers) + self.body = body + + def startline(self): + return "%s %d %s" % (self.version, self.code, reasondict[self.code]) def __str__(self): """Convert the Response to a string @@ -86,5 +131,5 @@ class Response(Message): Returns: str: representation the can be sent over socket """ - self.startline = "" return super(Response, self).__str__() + diff --git a/project1/proj1_s4498062/webhttp/message.pyc b/project1/proj1_s4498062/webhttp/message.pyc deleted file mode 100644 index 432142c..0000000 Binary files a/project1/proj1_s4498062/webhttp/message.pyc and /dev/null differ diff --git a/project1/proj1_s4498062/webhttp/parser.py b/project1/proj1_s4498062/webhttp/parser.py index f3809c3..58e83dc 100644 --- a/project1/proj1_s4498062/webhttp/parser.py +++ b/project1/proj1_s4498062/webhttp/parser.py @@ -11,7 +11,7 @@ class RequestParser: def __init__(self): """Initialize the RequestParser""" - pass + self.buff = '' def parse_requests(self, buff): """Parse requests in a buffer @@ -22,28 +22,27 @@ class RequestParser: Returns: list of webhttp.Request """ - requests = split_requests(buff) + self.buff += buff + requests = self.get_requests() http_requests = [] for request in requests: http_request = webhttp.message.Request() + http_request.parse(request) http_requests.append(http_request) return http_requests - def split_requests(self, buff): - """Split multiple requests - - Arguments: - buff (str): the buffer contents received from socket + def get_requests(self): + """Split multiple requests from buffer Returns: list of str """ - requests = buff.split('\r\n\r\n') + requests = self.buff.split('\r\n\r\n') requests = filter(None, requests) - requests = [r + '\r\n\r\n' for r in requests] - requests = [r.lstrip() for r in requests] + requests = [r.lstrip() + '\r\n\r\n' for r in requests] + self.buff = '' return requests @@ -63,4 +62,5 @@ class ResponseParser: webhttp.Response """ response = webhttp.message.Response() + response.parse(buff) return response diff --git a/project1/proj1_s4498062/webhttp/parser.pyc b/project1/proj1_s4498062/webhttp/parser.pyc deleted file mode 100644 index c5d9ced..0000000 Binary files a/project1/proj1_s4498062/webhttp/parser.pyc and /dev/null differ diff --git a/project1/proj1_s4498062/webhttp/regexes.py b/project1/proj1_s4498062/webhttp/regexes.py new file mode 100644 index 0000000..755bb9f --- /dev/null +++ b/project1/proj1_s4498062/webhttp/regexes.py @@ -0,0 +1,151 @@ +from os.path import commonprefix +from itertools import groupby +import re + +def grpm(regex): + return grp(regex, matching=True) + +def grp(regex, matching=False): + return r'(' + (r'' if matching else r'?:') + regex + r')' + +def opt(regex): + return grp(grp(regex) + r'?') + +def regex_opt_r(regexes): + return grp(r'|'.join(regexes)) + +# The below functions were taken from the pygments package +# (http://pygmenst.org), in particular pygments.regexopt and pygments.lexer +# Some small modifications have been made. +def regex_opt_inner(strings, open_paren): + """Return a regex that matches any string in the sorted list of strings.""" + close_paren = open_paren and ')' or '' + # print strings, repr(open_paren) + if not strings: + # print '-> nothing left' + return '' + first = strings[0] + if len(strings) == 1: + # print '-> only 1 string' + return open_paren + re.escape(first) + close_paren + if not first: + # print '-> first string empty' + return open_paren + regex_opt_inner(strings[1:], '(?:') \ + + '?' + close_paren + if len(first) == 1: + # multiple one-char strings? make a charset + oneletter = [] + rest = [] + for s in strings: + if len(s) == 1: + oneletter.append(s) + else: + rest.append(s) + if len(oneletter) > 1: # do we have more than one oneletter string? + if rest: + # print '-> 1-character + rest' + return open_paren + regex_opt_inner(rest, '') + '|' \ + + make_charset(oneletter) + close_paren + # print '-> only 1-character' + return make_charset(oneletter) + prefix = commonprefix(strings) + if prefix: + plen = len(prefix) + # we have a prefix for all strings + # print '-> prefix:', prefix + return open_paren + re.escape(prefix) \ + + regex_opt_inner([s[plen:] for s in strings], '(?:') \ + + close_paren + # is there a suffix? + strings_rev = [s[::-1] for s in strings] + suffix = commonprefix(strings_rev) + if suffix: + slen = len(suffix) + # print '-> suffix:', suffix[::-1] + return open_paren \ + + regex_opt_inner(sorted(s[:-slen] for s in strings), '(?:') \ + + re.escape(suffix[::-1]) + close_paren + # recurse on common 1-string prefixes + # print '-> last resort' + return open_paren + \ + '|'.join(regex_opt_inner(list(group[1]), '') + for group in groupby(strings, lambda s: s[0] == first[0])) \ + + close_paren + +def regex_opt(strings, prefix='', suffix=''): + """Return a compiled regex that matches any string in the given list. + + The strings to match must be literal strings, not regexes. They will be + regex-escaped. + + *prefix* and *suffix* are pre- and appended to the final regex. + """ + strings = sorted(strings) + return prefix + regex_opt_inner(strings, '(?:') + suffix + +## From here it is own work again +# RFC 2396 +IPv4address = grp(r'(?:\d{1,3}\.){3}\d{1,3}') +reserved = grp(r'[;\/?:@&=+$,]') +alphanum = grp(r'[\da-zA-Z]') +mark = grp(r'[\-_\.!~\*\'\(\)]') +hex = grp(r'[\da-fA-F]') +unreserved = regex_opt_r([alphanum, mark]) +escaped = grp(r'%' + hex + hex) +pchar = regex_opt_r([unreserved, escaped, r'[:@&=+$,]']) +param = grp(pchar + r'*') +segment = grp(pchar + r'*' + grp(r';' + param) + r'*') +path_segments = grp(segment + grp(r'\/' + segment) + r'*') +abs_path = grp(r'\/' + path_segments) +scheme = grp(r'[a-zA-Z](?:[a-zA-Z\d+\-\.]*)') +userinfo = grp(regex_opt_r([unreserved, escaped, r'[;:&=+$,]']) + r'*') +domainlabel = grp(r'[a-zA-Z\d]|(?:[a-zA-Z\d](?:[a-zA-Z\d\-])*[a-zA-Z\d])') +toplabel = grp(r'[a-zA-Z]|(?:[a-zA-Z](?:[a-zA-Z\d\-])*[a-zA-Z\d])') +hostname = grp(opt(domainlabel + r'\.') + r'*' + toplabel + opt(r'\.')) +host = regex_opt_r([hostname, IPv4address]) +port = r'\d+' +hostport = grp(host + opt(r':' + port)) +server = opt(opt(userinfo + r'@') + hostport) +reg_name = grp(regex_opt_r([unreserved, escaped, r'[;:&=+]']) + r'*') +authority = regex_opt_r([server, reg_name]) +net_path = grp(r'\/\/' + authority + opt(abs_path)) +hier_part = regex_opt_r([net_path, abs_path]) +uric = regex_opt_r([reserved, unreserved, escaped]) +uric_no_slash = regex_opt_r([unreserved, escaped, r'[;?:@&=+$,]']) +opaque_part = grp(uric_no_slash + grp(uric) + r'*') +absoluteURI = grp(scheme + r':' + regex_opt_r([hier_part, opaque_part])) + +# RFC 2616 +CTL = r'[\x00-\x1f\x7f]' +CR = r'\r' +LF = r'\n' +CRLF = CR + LF +HT = r'\t' +SP = r' ' +LWS = grp(opt(CRLF) + regex_opt_r([SP, HT])) +TEXT = grp(r'[^\x00-\x1f\x7f]|' + LWS) +TEXT_NO_LWS = r'[^\x00-\x1f\x7f \t]' + +separator = r'[\(\)<>@,;:\\"\/\[\]?=\{\} \t]' +token = r'[^\x00-\x1f\(\)<>@,;:\\"\/\[\]?=\{\} \t]+' +qdtext = r'^\x00-\x08\x0b-\x0c\x0e-\x1f\x7f"]' +quotedPair = r'\\[\x00-\x7f]' +quotedString = grp(r'"' + regex_opt_r([qdtext, quotedPair]) + r'*"') + +HTTPVersion = r'HTTP\/\d\.\d' +Method = regex_opt(['OPTIONS', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'TRACE', + 'CONNECT']) +RequestURI = regex_opt_r([r'\*', absoluteURI, abs_path, authority]) +RequestLine = grp(grpm(Method) + SP + grpm(RequestURI) + SP + + grpm(HTTPVersion) + CRLF) + +StatusCode = r'\d{3}' +ReasonPhrase = r'[^\r\n]*' +StatusLine = grp(grpm(HTTPVersion) + SP + grpm(StatusCode) + SP + + grpm(ReasonPhrase) + CRLF) + +FieldName = token +FieldContent = regex_opt_r([TEXT_NO_LWS + TEXT + r'*(?!' + LWS + r')']) +FieldValue = grp(regex_opt_r([grp(FieldContent), LWS]) + r'*') +MessageHeader = grp(grpm(FieldName) + r':' + grpm(FieldValue)) + diff --git a/project1/proj1_s4498062/webhttp/resource.py b/project1/proj1_s4498062/webhttp/resource.py index dc20067..cf2b725 100644 --- a/project1/proj1_s4498062/webhttp/resource.py +++ b/project1/proj1_s4498062/webhttp/resource.py @@ -4,7 +4,7 @@ This module contains a handler class for resources. """ import os -import mimetype +import mimetypes import urlparse @@ -34,8 +34,12 @@ class Resource: self.uri = uri out = urlparse.urlparse(uri) self.path = os.path.join("content", out.path.lstrip("/")) + if not os.path.exists(self.path): + raise FileExistError if os.path.isdir(self.path): self.path = os.path.join(self.path, "index.html") + if not os.path.exists(self.path): + raise FileAccessError if not os.path.isfile(self.path): raise FileExistError if not os.access(self.path, os.R_OK): diff --git a/project1/proj1_s4498062/webhttp/server.py b/project1/proj1_s4498062/webhttp/server.py index b540ac4..b385636 100644 --- a/project1/proj1_s4498062/webhttp/server.py +++ b/project1/proj1_s4498062/webhttp/server.py @@ -3,9 +3,13 @@ This module contains a HTTP server """ +from configparser import SafeConfigParser import threading import socket +from composer import ResponseComposer +from parser import RequestParser +import weblogging as logging class ConnectionHandler(threading.Thread): """Connection Handler for HTTP Server""" @@ -18,7 +22,7 @@ class ConnectionHandler(threading.Thread): addr (str): ip address of client timeout (int): seconds until timeout """ - super(HTTPConnectionHandler, self).__init__() + super(ConnectionHandler, self).__init__() self.daemon = True self.conn_socket = conn_socket self.addr = addr @@ -26,17 +30,35 @@ class ConnectionHandler(threading.Thread): def handle_connection(self): """Handle a new connection""" - pass + rp = RequestParser() + rc = ResponseComposer(timeout=self.timeout) + + while True: + data = self.conn_socket.recv(4096) + if len(data) == 0: + break + for req in rp.parse_requests(data): + logging.info("<-- %s" % req.startline()) + resp = rc.compose_response(req) + logging.info("--> %s" % resp.startline()) + sent = self.conn_socket.send(str(resp)) + if sent == 0: + raise RuntimeError('Socket broken') + + self.conn_socket.close() def run(self): """Run the thread of the connection handler""" - self.handle_connection() + try: + self.handle_connection() + except socket.error, e: + print('ERR ' + str(e)) class Server: """HTTP Server""" - def __init__(self, hostname, server_port, timeout): + def __init__(self, config, **kwargs): """Initialize the HTTP server Args: @@ -44,15 +66,26 @@ class Server: server_port (int): port that the server is listening on timeout (int): seconds until timeout """ - self.hostname = hostname - self.server_port = server_port - self.timeout = timeout + self.read_config(config, **kwargs) self.done = False + + def read_config(self, config, **kwargs): + self.cp = SafeConfigParser() + self.cp.read(config) + if not self.cp.has_section('webhttp'): + self.cp.add_section('webhttp') + for (name, val) in self.cp.items('webhttp') + kwargs.items(): + setattr(self, name, val) def run(self): """Run the HTTP Server and start listening""" + serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + serversocket.bind((self.hostname, self.port)) + serversocket.listen(5) while not self.done: - pass + (clientsocket, addr) = serversocket.accept() + ch = ConnectionHandler(clientsocket, addr, self.timeout) + ch.run() def shutdown(self): """Safely shut down the HTTP server""" diff --git a/project1/proj1_s4498062/webhttp/server.pyc b/project1/proj1_s4498062/webhttp/server.pyc deleted file mode 100644 index 7981ff7..0000000 Binary files a/project1/proj1_s4498062/webhttp/server.pyc and /dev/null differ diff --git a/project1/proj1_s4498062/webhttp/weblogging.py b/project1/proj1_s4498062/webhttp/weblogging.py new file mode 100644 index 0000000..d1792a3 --- /dev/null +++ b/project1/proj1_s4498062/webhttp/weblogging.py @@ -0,0 +1,9 @@ +import logging + +name = 'webhttp' + +def debug(msg, *args, **kwargs): + logging.getLogger(name).debug(str(msg), *args, **kwargs) + +def info(msg, *args, **kwargs): + logging.getLogger(name).info(str(msg), *args, **kwargs) diff --git a/project1/proj1_s4498062/webserver.py b/project1/proj1_s4498062/webserver.py index e518810..a361d5b 100644 --- a/project1/proj1_s4498062/webserver.py +++ b/project1/proj1_s4498062/webserver.py @@ -1,18 +1,43 @@ import argparse +import logging +import os.path +import sys + import webhttp.server +import webhttp.weblogging # Create and start the HTTP Server # Use `python webserver.py --help` to display command line options if __name__ == '__main__': # Parse command line arguments parser = argparse.ArgumentParser(description="HTTP Server") - parser.add_argument("-a", "--address", type=str, default="localhost") - parser.add_argument("-p", "--port", type=int, default=8001) - parser.add_argument("-t", "--timeout", type=int, default=15) + parser.add_argument('-c', '--config', type=str, default='~/.webpy.ini', + help='configuration file') + parser.add_argument('-a', '--address', type=str, default='localhost', + help='address to listen on (default localhost)') + parser.add_argument('-p', '--port', type=int, default=80, + help='port to listen on (default 80)') + parser.add_argument('-t', '--timeout', type=int, default=15, + help='timeout for incoming connections (default 15)') + parser.add_argument('-l', '--log', type=str, default='info', + help='log level (debug, info, warning, error, critical)') + parser.add_argument('-lf', '--log-file', type=str, default=None, + help='log file (default stdout)') args = parser.parse_args() + # Logging + fmt = '[%(asctime)s] %(process)d %(levelname)s %(message)s' + logging.basicConfig(format=fmt, level=getattr(logging, args.log.upper())) + if args.log_file != None: + logger = logging.getLogger(webhttp.weblogging.name) + handler = logging.FileHandler(args.log_file) + handler.setLevel(getattr(logging, args.log.upper())) + logger.addHandler(handler) + # Start server - server = webhttp.server.Server(args.address, args.port, args.timeout) + config = os.path.expanduser(os.path.expandvars(args.config)) + server = webhttp.server.Server(config=config, + address=args.address, port=args.port, timeout=args.timeout) try: server.run() except KeyboardInterrupt: diff --git a/project1/proj1_s4498062/webtests.py b/project1/proj1_s4498062/webtests.py index 9b0cdbf..a869de1 100644 --- a/project1/proj1_s4498062/webtests.py +++ b/project1/proj1_s4498062/webtests.py @@ -17,31 +17,43 @@ class TestGetRequests(unittest.TestCase): self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client_socket.connect(("localhost", portnr)) self.parser = webhttp.parser.ResponseParser() + self.default_headers = [ + ('Host', 'localhost:%d' % portnr), + ('Connection', 'close') + ] def tearDown(self): """Clean up after testing""" self.client_socket.shutdown(socket.SHUT_RDWR) self.client_socket.close() - def test_existing_file(self): - """GET for a single resource that exists""" - # Send the request + def request(self, method, uri, headers): request = webhttp.message.Request() - request.method = "GET" - request.uri = "/test/index.html" - request.set_header("Host", "localhost:{}".format(portnr)) - request.set_header("Connection", "close") + request.method = method + request.uri = uri + for name, value in headers: + request.set_header(name, value) + self.client_socket.send(str(request)) - # Test response message = self.client_socket.recv(1024) response = self.parser.parse_response(message) + + return response + + def test_existing_file(self): + """GET for a single resource that exists""" + response = self.request( + 'GET', '/test/index.html', self.default_headers) self.assertEqual(response.code, 200) self.assertTrue(response.body) def test_nonexistant_file(self): """GET for a single resource that does not exist""" - pass + response = self.request( + 'GET', '/test/nonexistant.html', self.default_headers) + self.assertEqual(response.code, 404) + self.assertTrue(response.body) def test_caching(self): """GET for an existing single resource followed by a GET for that same @@ -51,11 +63,14 @@ class TestGetRequests(unittest.TestCase): def test_extisting_index_file(self): """GET for a directory with an existing index.html file""" - pass + self.assertEqual(self.request('GET', '/test', self.default_headers), + self.request('GET', '/test/index.html', self.default_headers)) def test_nonexistant_index_file(self): """GET for a directory with a non-existant index.html file""" - pass + response = self.request('GET', '/test/no-index', self.default_headers) + self.assertEqual(response.code, 403) + self.assertTrue(response.body) def test_persistent_close(self): """Multiple GETs over the same (persistent) connection with the last @@ -86,6 +101,8 @@ if __name__ == "__main__": # Arguments for the unittest framework parser.add_argument('unittest_args', nargs='*') args = parser.parse_args() + + portnr = args.port # Only pass the unittest arguments to unittest sys.argv[1:] = args.unittest_args -- cgit v1.2.3