Skip to content

Instantly share code, notes, and snippets.

@brunobord
Last active June 15, 2020 00:30
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brunobord/f9b5c57b4b10d4cb95d3410c004a0f40 to your computer and use it in GitHub Desktop.
Save brunobord/f9b5c57b4b10d4cb95d3410c004a0f40 to your computer and use it in GitHub Desktop.
Simple static server in Python
#!/usr/bin/env python3
"""
Usage:
./static.py # serve current directory on port 8080
./static.py --port 9090 /path/to/serve
Why that? because `python -m http.server` can't serve a specified directory.
What's the use case? Let's say we're building a Sphinx documentation, situated
in the `build/html` directory. With the standard http.server lib, I **have**
to move to `build/html` directory and then run my HTTP server. And what happens
if I delete this directory, **and** recreate it by building the docs while my
server is on? It fails to serve files, it's lost. And `ctrl-c` it does not work
better, the shell is still lost in oblivion.
Can't stand this.
Works with Python 3, tested with Python 3.4, 3.5, 3.6.
Uses only the standard lib.
To use via HTTPS, use the ``--ssl`` option to point at a ".pem" file path.
You can create a self-signed file using the following command:
openssl req -new -x509 -keyout server.pem -out server.pem -days 365 -nodes
Warning: You'll probably have an "unsecure connection" error page in your
browser, with the code "SEC_ERROR_UNKNOWN_ISSUER". You can add an exception
(permanent or temporary) to access the static pages.
"""
import sys
import argparse
from contextlib import contextmanager
from os import getcwd, chdir
from os.path import abspath, isdir, isfile
from http.server import HTTPServer, HTTPStatus
from http.server import SimpleHTTPRequestHandler as BaseHTTPRequestHandler
import ssl
class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
FAIL = '\033[91m'
ENDC = '\033[0m'
def log_request(self, code='-', size='-'):
"""Log an accepted request.
This is called by send_response().
"""
error = None
if isinstance(code, HTTPStatus):
code = code.value
if 100 <= code < 300:
error = False
elif 300 <= code < 400:
error = None
else:
error = True
self.log_message('"%s" %s %s',
self.requestline, str(code), str(size),
error=error)
def log_error(self, format, *args, error=None):
if format == "code %d, message %s":
return
self.log_message(format, *args, error=True)
def log_message(self, format, *args, error=None):
message = "%s - - [%s] %s\n" % (
self.address_string(),
self.log_date_time_string(),
format % args)
if error is None:
# can't see a case where it would happend, but we don't know
color = self.OKBLUE
else:
if error:
color = self.FAIL
else:
color = self.OKGREEN
message = "{}{}{}".format(color, message, self.ENDC)
sys.stderr.write(message)
@contextmanager
def cd(dirname):
"""
Context manager to temporarily change current directory.
"""
_curdir = getcwd()
chdir(dirname)
yield
chdir(_curdir)
class RequestHandler(SimpleHTTPRequestHandler):
def _directory_dance(self):
# We're testing if the current directory still exists.
try:
getcwd()
except FileNotFoundError:
chdir('.')
root = getattr(self.server, '__root')
chdir(root)
def do_HEAD(self):
"""
Root-removal-tolerant HEAD method handling.
See do_GET for a little explanation.
"""
try:
self._directory_dance()
except FileNotFoundError:
self.send_error(404, "Root not found, come back later")
else:
return super().do_HEAD()
def do_GET(self):
"""
Root-removal-tolerant GET method.
TL;DR: trying to load a removed directory content doesn't please
getcwd(). Even if the directory is recreated.
* if the root directory exists, proceed.
* if it doesn't exist, return a 404 with a significant message.
* if it *has* existed and it was removed, and then recreated, will make
a little dance to try getting back to it and make it the current
directory again.
"""
try:
self._directory_dance()
except FileNotFoundError:
self.send_error(404, "Root not found, come back later")
else:
return super().do_GET()
def port(number):
"""
A port value is a number between 0 and 65535.
"""
try:
number = int(number)
except ValueError:
raise argparse.ArgumentTypeError(
"invalid int value: '{}'".format(number))
if 0 <= number <= 65535:
return number
raise argparse.ArgumentTypeError("port must be 0-65535")
def certfile(path):
"""
A certfile is a string that points at an existing file.
"""
path = '{}'.format(path)
if path and not isfile(path):
raise argparse.ArgumentTypeError(
"`{}` is not a known file".format(path)
)
return path
def serve(root, port, certfile_path=None):
"""
Serve files
"""
scheme = 'http'
if certfile_path:
scheme = 'https'
root = abspath(root)
if not isdir(root):
print("Error: `{}` is not a directory".format(root))
return
print(
"Serving `{root}`..."
"\nGo to: {scheme}://127.0.0.1:{port}".format(
scheme=scheme, root=root, port=port)
)
with cd(root):
server_address = ('', port)
httpd = HTTPServer(server_address, RequestHandler)
httpd.__root = root
if certfile_path:
httpd.socket = ssl.wrap_socket(
httpd.socket,
server_side=True,
certfile=certfile_path,
ssl_version=ssl.PROTOCOL_TLSv1
)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
print("Bye...")
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
'root', nargs='?', default='.',
help="Path to serve statically")
parser.add_argument(
'--port', '-p', type=port, default=8080,
help="Port number (0-65535)")
parser.add_argument(
'--ssl', type=certfile, default=None,
help='Path to certfile (.pem format). In that case, you will need to'
' access the pages via HTTPS.'
)
args = parser.parse_args()
serve(args.root, args.port, args.ssl)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment