# -*- coding: utf-8 -*-
"""
Server
------
HTTP/HTTPS python server which exposes behaviour through the :class:`Server`
class.
.. code::
>>> server = start_server()
>>> server.data['response_content'] = b'this is response body text'
>>> response = requests.get(server.url('/test'))
>>> response.content
'this is response body text'
"""
import os
import ssl
import time
import logging
import contextlib
from threading import Thread, RLock
from ._compat import (iteritems, ThreadingMixIn, HTTPServer,
BaseHTTPRequestHandler)
def here(path):
return os.path.abspath(os.path.realpath(
os.path.join(os.path.dirname(__file__), path)))
DEFAULT_HOST = '127.0.0.1' # loopback
DEFAULT_PORT = 0 # random port
DEFAULT_CERTFILE = here('./server.pem') # cert + private key
lock = RLock()
log = logging.getLogger('httptestserver')
def start_server(host=None, port=None):
"""Create a started HTTP server listening in *host*:*port*
:param host: *(default: 127.0.0.1)* Host for the server to listen.
:param port: *default: random)* Port of the server to listen (should not be in use).
:returns: A created and started :class:`Server`
"""
return Server.start_server(host or DEFAULT_HOST, port or DEFAULT_PORT)
def start_ssl_server(host=None, port=None, certfile=None, keyfile=None):
"""Create a started HTTPS server listening in *host*:*port*
It configures server certificate using *certfile* and *keyfile*.
:param host: *(default: 127.0.0.1)* Host for the server to listen.
:param port: *(default: random)* Port of the server to listen (should not be in use).
:param certfile: *(default: packaged .pem)* Path to certificate file as
accepted by :class:`HTTPServer`.
:param keyfile: *(default: None)* Path to private key file as accepted by
:class:`HTTPServer`. Default comes bundled with *certfile*.
:returns: A created and started :class:`Server`
"""
return Server.start_ssl_server(host or DEFAULT_HOST, port or DEFAULT_PORT,
certfile or DEFAULT_CERTFILE, keyfile)
[docs]class Handler(BaseHTTPRequestHandler):
"""Handles all requests and collects server data
Handles all the requests on the :meth:`handle_request` method which is
also responsible for building a response.
The :attr:`Server.data` dictionary is updated on at the begining of each
request with the current server state. See :class:`BaseHTTPRequestHandler`
documentation for the full list of server attributes available.
The default handler behaviour can be controlled through
:attr:`Server.data`.
"""
[docs] def handle_request(self):
"""Handles server request/response"""
log.info('Processing %s request', self.command)
# Read and process a http request
# Create and send a http response
self.update_state() # Save server current state
self.read_content() # Read request body
self.process_request() # Process received request
self.send_headers() # Send response headers
self.send_content() # Send response body
self.save_history() # Save current state in history
self.finish_request() # Finish headers and response
def read_content(self):
# Read request body (if any)
if self.command in ('POST', 'PUT', 'PATCH'):
content_length = self.headers['Content-Length']
log.info(u'Content-Length: %s', content_length)
self.server.data['body'] = self.rfile.read(int(content_length))
def process_request(self):
# Simulate timeouts
timeout = self.server.data.get('response_timeout')
if timeout is not None:
log.info(u'Server sleeping for: %d s', timeout)
time.sleep(timeout)
def send_headers(self):
# Send status code
status_code = self.server.data.get('response_status', 200)
log.info('Server returning status code %d', status_code)
self.send_response(status_code)
# Add user defined headers
headers = self.server.data.get('response_headers', ())
for field, content in iteritems(headers):
log.info(u'Server setting response header %s: %s', field, content)
self.send_header(field, content)
self.end_headers()
def send_content(self):
response_content = self.server.data.get('response_content')
if response_content is not None:
log.info(u'Server sending content: %d bytes', len(response_content))
self.wfile.write(response_content)
def finish_request(self):
# Avoid same behaviour on next request
if self.server.data.get('response_clear'):
self.server.reset_response_data()
if self.server.data.get('response_reset'):
self.server.reset()
@property
[docs] def state(self):
"""Dict with the current server state"""
return self.__dict__
[docs] def update_state(self):
"""Copies current server state"""
self.server.data.update(self.state)
[docs] def save_history(self):
"""Create a new entry in history"""
self.server.save_history()
def __getattr__(self, name):
# redirect all requests to handle_request
# See implementation of BaseHTTPRequestHandler.handle_one_request
if name.startswith('do_'):
return self.handle_request
return super(Handler, self).__getattribute__(name)
class Server(ThreadingMixIn, HTTPServer, Thread):
"""HTTP Server
Starts in a child thread.
Thread stops and closes when the caller does.
Handles each request on a new thread, *forks* on each request.
Server state after each request can be checked as a `dict` through the
thread-save attribute :attr:`data`, which is updated at the begining of
each request. See :class:`Handler` and :class:`BaseHTTPRequestHandler` to
see the information available on that `dict`.
.. code::
>> server.data
{'requestline': 'GET /url HTTP/1.1', 'path': '/url', ...}
if several requests are made, their state are kept in order in the history:
.. code::
>> server.history
[
{'path': '/first', ..},
{'path': '/second', ..}
]
*About multithreading:* It is necessary that each request gets serverd by
a different thread, in case that more than one request is made at the same
time. If any two requests are attended at the same time by the *same
thread*, risk of deadlock exists.
"""
def __init__(self, host, port, scheme='http', handler=Handler):
"""Creates a new :class:`Server`
:param host: Host for the server to listen.
:param port: Port of the server to listen (should free).
:param scheme: *(default: http)* 'http' or 'https'.
:param handler: (default: :class:`Handler`) A
:class:`BaseHTTPRequestHandler` class.
"""
Thread.__init__(self)
HTTPServer.__init__(self, (host, port), handler)
self._data = {}
self._history = []
self.daemon = True # se cierra el solo al terminar proceso
self.scheme = scheme
@classmethod
def start_server(cls, host, port):
"""Creates and starts a http :class:`Server`
:param host: Host for the server to listen.
:param port: Port of the server to listen (should not be in use).
:returns: A created and started http :class:`Server`
"""
log.info('Starting http server %s:%d', host, port)
server = cls(host, port, 'http')
server.start()
return server
@classmethod
def start_ssl_server(cls, host, port, certfile, keyfile):
"""Creates and starts a https :class:`Server`
:param host: Host for the server to listen.
:param port: Port of the server to listen (should not be in use).
:param certfile: Path to certificate file as
accepted by :class:`HTTPServer`.
:param keyfile: Path to private key file as accepted by
:class:`HTTPServer`. Default it's bundled with *certfile*.
:returns: A created and started https :class:`Server`
"""
log.info('Starting https server %s:%d', host, port)
log.debug('Using certfile: "%s"', certfile)
log.debug('Using keyfile: "%s"', keyfile)
server = cls(host, port, 'https')
server.socket = ssl.wrap_socket(
server.socket, server_side=True, certfile=certfile, keyfile=keyfile)
server.start()
return server
@property
def host(self):
return self.server_address[0]
@property
def port(self):
return self.server_address[1]
@property
def data(self):
"""Gives access to current server state `dict` (read-write)
List of values that can be set to control the server behaviour:
response_status
An `int` with the status code of the next response.
response_headers
A `dict` or a `(k, v) tuple` with all the headers to be sent on
the next response.
response_content
A `bytes` with the body of the next response.
response_timeout
A number with the time in seconds to wait before starting a response.
response_clear
`True` if server user state should be reset after responding.
This is useful when responding with `3xx` redirections.
response_reset
`True` if server state should be totally reset after the response.
"""
with lock:
return self._data
def reset(self):
"""Resets all server data in :attr:`data`"""
with lock:
self._data = {}
self._history = []
@property
def history(self):
"""Gives access to all the server states in a `list` (read-only)"""
with lock:
return self._history
def save_history(self):
self._history.append(dict(self.data))
@property
def response_data(self):
"""All user-defined response properties"""
return {k: v for k, v in self.data.items() if k.startswith('response_')}
def reset_response_data(self):
for k in self.response_data:
del self.data[k]
def url(self, path):
"""Compose a full URL to the server from the url path:
.. code::
>> server.url('/test/url')
http://127.0.0.1:8888/test/url
"""
return "{}://{}:{}{}".format(self.scheme, self.host, self.port, path)
def run(self):
self.serve_forever()
@contextlib.contextmanager
def http_server(*args, **kwargs):
"""Context of a started HTTP :class:`Server`
.. code::
with http_server() as server:
# use server
See function :func:`start_server`.
"""
server = start_server(*args, **kwargs)
yield server
@contextlib.contextmanager
def https_server(*args, **kwargs):
"""Context of a started HTTPS :class:`Server`
.. code::
with https_server() as server:
# use server
See function :func:`start_ssl_server`.
"""
server = start_ssl_server(*args, **kwargs)
yield server