3 # Copyright (C) 2010-2013 W. Trevor King <wking@tremily.us>
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 """Gallery server for a picture directory organized along::
23 | |-- another_picture.jpg
27 | |-- captioned_picture.jpg
28 | |-- captioned_picture.jpg.txt
35 Note that you can store a caption for ``<PICTURE>`` as plain text in
38 See RFC 3875 for more details on the the Common Gateway Interface (CGI).
40 Besides the CGI interface, this script can also be run as:
42 * a Simple Common Gateway Interface (SCGI) with the ``--mode=scgi`` option
43 * a stand-alone server with the ``--mode=wsgi`` option
46 import collections as _collections
47 import logging as _logging
48 import logging.handlers as _logging_handlers
50 import mimetypes as _mimetypes
52 import os.path as _os_path
53 import random as _random
55 import subprocess as _subprocess
57 import urllib.parse as _urllib_parse
58 except ImportError: # Python 2
59 import urlparse as _urllib_parse
60 import urllib as _urllib
61 _urllib_parse.urlencode = _urllib.urlencode
62 import xml.sax.saxutils as _xml_sax_saxutils
68 IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.tif', '.tiff', '.png', '.gif']
69 VIDEO_EXTENSIONS = ['.mov', '.mp4', '.ogv']
70 STREAMING_TYPES = ['video/ogg']
71 RESPONSES = { # httplib takes half a second to load
75 500: 'Internal Server Error',
78 LOG = _logging.getLogger('gallery.py')
79 LOG.addHandler(_logging.StreamHandler())
80 #LOG.addHandler(_logging_handlers.SysLogHandler())
81 LOG.handlers[0].setFormatter(
82 _logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
83 LOG.setLevel(_logging.DEBUG)
84 #LOG.setLevel(_logging.WARNING)
87 class CommandError(Exception):
88 def __init__(self, command, status, stdout=None, stderr=None):
89 strerror = ['Command failed (%d):\n %s\n' % (status, stderr),
90 'while executing\n %s' % str(command)]
91 super(CommandError, self).__init__('\n'.join(strerror))
92 self.command = command
98 class HTTPError(Exception):
99 def __init__(self, status, message=None, content=None):
101 message = RESPONSES[status]
104 super(HTTPError, self).__init__('{} {}'.format(status, message))
106 self.message = message
107 self.content = content
110 class ProcessingComplete(Exception):
111 def __init__(self, headers=None):
112 self.headers = headers
115 def invoke(args, stdin=None, stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
116 expect=(0,), cwd=None, encoding=None):
118 expect should be a tuple of allowed exit codes. cwd should be
119 the directory from which the command will be executed. When
120 unicode_output == True, convert stdout and stdin strings to
121 unicode before returing them.
125 LOG.debug('{}$ {}'.format(cwd, ' '.join(args)))
127 q = _subprocess.Popen(args, stdin=_subprocess.PIPE, stdout=stdout,
128 stderr=stderr, cwd=cwd)
130 raise CommandError(args, status=e.args[0], stderr=e)
131 stdout,stderr = q.communicate(input=stdin)
133 LOG.debug('{:d}\n{}{}'.format(status, stdout, stderr))
134 if status not in expect:
135 raise CommandError(args, status, stdout, stderr)
136 return status, stdout, stderr
138 def is_image(filename):
139 for extension in IMAGE_EXTENSIONS:
140 if filename.lower().endswith(extension):
144 def is_video(filename):
145 for extension in IMAGE_EXTENSIONS:
146 if filename.lower().endswith(extension):
150 def image_base(filename):
151 parts = filename.rsplit('.', 1)
152 assert len(parts) == 2, parts
156 class CGIGalleryServer (object):
157 def __init__(self, base_path='.',
159 cache_path='/tmp/gallery-cache/',
160 serve_originals=True,
161 write_http_headers=True):
162 self._base_path = _os_path.abspath(base_path)
163 self._base_url = base_url
164 self._cache_path = cache_path
165 self._serve_originals = serve_originals
166 self._write_http_headers = write_http_headers
167 self._text_charset = 'UTF-8'
168 self._url_regexp = _re.compile('^[a-zA-Z0-9._/-]*$')
174 def _get_charset(self, headers):
175 content_type = headers.get('Content-type', '')
176 if 'charset=' in content_type:
177 return content_type.split('charset=', 1)[-1]
179 def _http_headers(self, mime='text/html', status=200):
180 msg = RESPONSES[status]
181 headers = _collections.OrderedDict()
182 headers['Status'] = '{:d} {}'.format(status, msg)
183 if mime.startswith('text/'):
184 charset = '; charset={}'.format(self._text_charset)
187 headers['Content-type'] = '{}{}'.format(mime, charset)
190 def _add_header(self, headers=None, stream=None):
191 if headers and self._write_http_headers:
192 for key, value in headers.items():
194 '{}: {}\r\n'.format(key, value).encode('US-ASCII'))
195 stream.write('\r\n'.encode('US-ASCII'))
197 def _response(self, headers=None, content='<h1>It works!</h1>',
200 headers = self._http_headers()
201 self._add_header(headers=headers, stream=stream)
202 charset = self._get_charset(headers=headers)
204 content = content.encode(charset)
205 stream.write(content)
206 raise ProcessingComplete(headers=headers)
208 def _response_stream(self, headers=None, content=None, stream=None,
210 LOG.debug('streaming response')
212 headers = self._http_headers()
213 charset = self._get_charset(headers=headers)
217 content='charset {} set for streamed response'.format(charset))
218 self._add_header(headers=headers, stream=stream)
219 stream.flush() # flush headers
221 chunk = content.read(chunk_size)
225 raise ProcessingComplete(headers=headers)
227 def _error(self, status=404, content=None, stream=None):
228 headers = self._http_headers(status=status)
230 content = RESPONSES[status]
231 self._response(headers=headers, content=content, stream=stream)
233 def validate_url(self, url, exists=True, directory=False):
234 LOG.debug('validating {} (exists={}, directory={})'.format(
235 repr(url), exists, directory))
238 elif (not self._url_regexp.match(url) or
239 url.startswith('/') or
242 LOG.error('invalid url')
245 path = _os_path.join(self._base_path, url)
247 if not _os_path.isdir(path):
248 LOG.error('nonexistent directory')
251 if not _os_path.isfile(path):
252 raise HTTPError(404, content='nonexistent file')
254 def serve(self, url=None, page=0, stream=None):
255 LOG.info('serving url {} (page {})'.format(url, page))
259 self.index(stream=stream, page=page)
260 elif url.endswith('random'):
262 url=url, stream=stream, max_width=500, max_height=500)
263 elif self.is_cacheable(url=url):
264 self.validate_url(url=url, exists=False)
265 self.cached(url=url, stream=stream)
267 self.validate_url(url=url, exists=False, directory=True)
268 self.page(url=url, page=page, stream=stream)
269 raise HTTPError(404, content='unexpected URL type')
270 except HTTPError as e:
272 self._error(e.status, content=e.content, stream=stream)
273 except ProcessingComplete as e:
276 def page_from_query(self, query=None, query_string=None):
277 """Extract the requested page from a query string
279 This is a helper method for CGIGalleryServer consumers.
280 Specify either query or query_string, but not both.
283 query = _urllib_parse.parse_qs(query_string)
287 if isinstance(pp, list):
295 def relative_url(self, url):
298 if not url.startswith(self._base_url):
299 content = 'cannot convert {} to a relative URL of {}'.format(
301 raise HTTPError(404, content=content)
302 if url == self._base_url:
304 return url[len(self._base_url):]
306 def _url(self, path, query=None):
307 relpath = _os_path.relpath(
308 _os_path.join(self._base_path, path), self._base_path)
311 elif path.endswith('/'):
314 relpath = '{}?{}'.format(relpath, _urllib_parse.urlencode(query))
315 return '{}{}'.format(self._base_url, relpath)
317 def _label(self, path):
318 dirname,base = _os_path.split(path)
319 if not base: # directory path ending with '/'
320 dirname,base = _os_path.split(dirname)
321 return base.replace('_', ' ').title()
323 def _link(self, path, query=None, text=None):
325 text = self._label(path)
326 return '<a href="{}">{}</a>'.format(
327 self._url(path=path, query=query), text)
329 def _subdirs(self, path):
331 order = [d.strip() for d in
332 open(_os_path.join(path, '_order')).readlines()]
335 dirs = sorted(_os.listdir(path))
341 for d in start + dirs:
342 dirpath = _os_path.join(path, d)
343 if _os_path.isdir(dirpath):
346 def _images(self, path):
347 for p in sorted(_os.listdir(path)):
348 if p.startswith('.') or p.endswith('~'):
350 picture_path = _os_path.join(path, p)
351 if is_image(picture_path):
354 def index(self, page=0, stream=None):
355 LOG.debug('index page')
356 return self._directory(path=self._base_path, page=page, stream=stream)
358 def _original_url(self, url):
359 """Reverse thumbnail URL mapping
361 Returns (original_url, generating_callback, callback_kwargs).
363 base,extension = _os_path.splitext(url)
364 if extension in ['.png']:
366 root,width,height = base.rsplit('-', 2)
369 400, content='missing width/height in {}'.format(base))
373 except ValueError as e:
375 400, content='invalid width/height: {}'.format(e))
380 'max_height': height},
382 elif extension in VIDEO_EXTENSIONS:
385 getattr(self, '_{}'.format(extension), None),
388 raise HTTPError(400, content='no original URL for {}'.format(url))
390 def _thumb(self, image, max_width=None, max_height=None):
391 if not _os_path.exists(self._cache_path):
392 _os.makedirs(self._cache_path)
393 dirname,filename = _os_path.split(image)
394 reldir = _os_path.relpath(dirname, self._base_path)
395 cache_dir = _os_path.join(self._cache_path, reldir)
396 if not _os_path.isdir(cache_dir):
397 _os.makedirs(cache_dir)
398 extension = '-{:d}-{:d}.png'.format(max_width, max_height)
399 thumb_filename = image_base(filename)+extension
400 thumb_url = _os_path.join(dirname, thumb_filename)
401 thumb_path = _os_path.join(cache_dir, thumb_filename)
402 image_path = _os_path.join(self._base_path, image)
403 if not _os_path.isfile(image_path):
405 404, content='image path for thumbnail does not exist')
406 if (not _os_path.isfile(thumb_path)
407 or _os_path.getmtime(image_path) > _os_path.getmtime(thumb_path)):
408 invoke(['convert', '-format', 'png', '-strip', '-quality', '95',
410 '-thumbnail', '{:d}x{:d}'.format(max_width, max_height),
412 return (thumb_path, self._url(thumb_url))
414 def _mp4(self, video, *args):
415 if not video.endswith('.mov'):
417 500, content="can't translate {} to MPEGv4".format(video))
418 dirname,filename = _os_path.split(video)
419 mp4_filename = image_base(filename) + '.mp4'
420 reldir = _os_path.relpath(dirname, self._base_path)
421 cache_dir = _os_path.join(self._cache_path, reldir)
422 if not _os_path.isdir(cache_dir):
423 _os.makedirs(cache_dir)
424 mp4_url = _os_path.join(dirname, mp4_filename)
425 mp4_path = _os_path.join(cache_dir, mp4_filename)
426 if not _os_path.isfile(video):
427 raise HTTPError(404, content='source video path does not exist')
428 if (not _os_path.isfile(mp4_path)
429 or _os_path.getmtime(video) > _os_path.getmtime(mp4_path)):
430 arg = ['ffmpeg', '-i', video, '-acodec', 'libfaac', '-aq', '200',
431 '-ac', '1', '-s', '640x480', '-vcodec', 'libx264',
432 '-preset', 'slower', '-vpre', 'ipod640', '-b:a', '128k',
433 '-b:v', '800k', '-aspect', '640:480', '-threads', '0']
437 return (mp4_path, self._url(mp4_url))
439 def _ogv(self, video, *args):
440 if not video.endswith('.mov'):
441 LOG.error("can't translate {} to Ogg Video".format(video))
442 dirname,filename = _os_path.split(video)
443 ogv_filename = image_base(filename) + '.ogv'
444 reldir = _os_path.relpath(dirname, self._base_path)
445 cache_dir = _os_path.join(self._cache_path, reldir)
446 if not _os_path.isdir(cache_dir):
447 _os.makedirs(cache_dir)
448 ogv_url = _os_path.join(dirname, ogv_filename)
449 ogv_path = _os_path.join(cache_dir, ogv_filename)
450 if not _os_path.isfile(video):
451 LOG.error('source video path does not exist')
452 if (not _os_path.isfile(ogv_path)
453 or _os_path.getmtime(video) > _os_path.getmtime(ogv_path)):
454 arg = ['ffmpeg2theora', '--optimize']
456 arg.extend(['--output', ogv_path, video])
458 return (ogv_path, self._url(ogv_url))
460 def _get_image_caption(self, path):
461 caption_path = path + '.txt'
463 return open(caption_path, 'r').read()
467 def _get_image_video(self, path, fallback=None):
468 base_path = image_base(path)
469 for extension in VIDEO_EXTENSIONS:
470 video_path = base_path + extension
471 if _os_path.isfile(video_path):
472 return self._video(video_path, fallback=fallback)
475 def _captioned_video(self, path, href=None):
476 img = self._image(path, max_width=640, max_height=480)
477 caption = self._get_image_caption(path)
478 video = self._get_image_video(path, fallback=[img])
481 content.extend(video)
483 content.append('<p>{}</p>'.format(
484 self._link(path=href, text='gallery page')))
486 content.append(self._link(path=href, text=img))
490 caption = _xml_sax_saxutils.escape(caption)
491 content.append('<p>{}</p>'.format(caption))
494 def _video(self, video, fallback=None, **kwargs):
497 '<p>Your browser does not support the <video> tag, try',
498 'downloading the video and playing it in an external player.',
501 fallback = [' '+line for line in fallback]
502 ogv_path,ogv_url = self._ogv(video)
503 mp4_path,mp4_url = self._mp4(video)
506 (' <video preloads="none" controls="controls" '
507 'width="640" height="480">'),
508 ' <source src="{}"'.format(mp4_url),
509 (''' type='video/mp4; '''
510 '''codecs="avc1.42E01E, mp4a.40.2"' />'''),
511 ' <source src="{}"'.format(ogv_url),
512 ''' type='video/ogg; codecs="theora,vorbis"' />''',
517 ' <a href="{}">Ogg/Theora/Vorbis</a> or'.format(ogv_url),
518 (' <a href="{}">Mpeg4/H.264(ConstrainedBaselineProfile)/AAC</a>.'
523 def _image(self, image, **kwargs):
525 image_path,image_url = self._thumb(image, **kwargs)
528 sections = ['<img src="{}"'.format(image_url)]
529 caption = self._get_image_caption(path=image)
531 caption = _xml_sax_saxutils.quoteattr(
532 caption.replace('\n', ' ').strip())
534 'title={}'.format(caption),
535 'alt={}'.format(caption),
537 sections.append('/>')
538 return ' '.join(sections)
540 def _image_page(self, image):
541 return image_base(image) + '/'
543 def random(self, url=None, stream=None, **kwargs):
544 LOG.debug('random image')
545 if url.endswith('/random'):
546 url = url[:(-len('/random'))]
547 self.validate_url(url=url, directory=True, stream=stream)
548 base_dir = _os_path.join(self._base_path, url)
549 elif url == 'random':
550 base_dir = self._base_path
554 for dirpath,dirnames,filenames in _os.walk(base_dir):
555 for filename in filenames:
556 if is_image(filename):
557 images.append(_os_path.join(dirpath, filename))
559 self._response(content='<p>no images to choose from</p>',
561 image = _random.choice(images)
562 LOG.debug('selected random image {}'.format(image))
563 page = self._image_page(image)
564 content = self._captioned_video(path=image, href=page)
565 self._response(content='\n'.join(content), stream=stream)
567 def is_cacheable(self, url):
568 return is_image(url) or is_video(url)
570 def cached(self, url, stream=None):
571 LOG.debug('retrieving possibly cached item')
572 mime = _mimetypes.guess_type(url)[0]
575 500, content='unknown mime type for {}'.format(url))
576 cache_path = _os_path.join(self._cache_path, url)
577 original_path = _os_path.join(self._base_path, url)
579 if _os_path.isfile(cache_path):
580 LOG.debug('return cached item {}'.format(cache_path))
582 elif self._serve_originals and _os_path.isfile(original_path):
583 LOG.debug('return original item {}'.format(original_path))
586 LOG.debug('possibly create cached item {}'.format(cache_path))
587 original_url,callback,kwargs = self._original_url(url)
588 original_path = _os_path.join(self._base_path, original_url)
589 if callback and _os_path.isfile(original_path):
590 path,cache_url = callback(original_path, **kwargs)
594 content = open(path, 'rb')
597 raise HTTPError(404, content='item not found {}'.format(url))
598 headers = self._http_headers(mime=mime)
599 if mime in STREAMING_TYPES:
600 self._response_stream(
601 headers=headers, content=content, stream=stream)
602 content = content.read()
603 self._response(headers=headers, content=content, stream=stream)
605 def page(self, url, page=0, stream=None):
606 LOG.debug('HTML page {} {}'.format(url, page))
607 if not url.endswith('/'):
609 404, content='HTML page URLs must end with a slash')
610 abspath = _os_path.join(self._base_path, url)
611 if _os_path.isdir(abspath):
612 self._directory(path=abspath, page=page, stream=stream)
613 for extension in IMAGE_EXTENSIONS:
614 file_path = abspath[:-1] + extension
615 if _os_path.isfile(file_path):
616 self._page(path=file_path, stream=stream)
617 raise HTTPError(404, content='unknown HTML page {}'.format(url))
619 def _directory_header(self, path):
620 relpath = _os_path.relpath(path, self._base_path)
624 dirname,base = _os_path.split(dirname)
626 crumbs.insert(0, base)
628 links = [None] * len(crumbs)
629 for i,c in enumerate(crumbs):
630 if i < len(crumbs)-1:
632 links[i] = self._link(self._base_path, 'Gallery')
634 relpath = '/'.join(crumbs[1:i+1]) + '/'
635 fullpath = _os_path.join(self._base_path, relpath)
636 links[i] = self._link(path=fullpath)
641 links[i] = self._label(crumbs[i])
642 content = ['<h1>{}</h1>'.format(' '.join(links))]
645 def _directory_page_navigation(self, path, page, pages):
648 prev_query = {'pp': (page - 1) % pages + 1}
649 next_query = {'pp': (page + 1) % pages + 1}
651 '<div style="text-align: center;">',
653 self._link(path=path, query=prev_query, text='previous'),
654 '({:d} of {:d})'.format(page+1, pages),
655 self._link(path=path, query=next_query, text='next'),
660 def _directory_subdirs(self, path):
662 dirs = list(self._subdirs(path))
664 content.append('<ul>')
666 content.append(' <li>{}</li>'.format(self._link(d+'/')))
667 content.append('</ul>')
670 def _directory_images(self, path, images):
671 content = ['<table style="margin-left: auto; margin-right: auto;">']
674 page = self._image_page(image)
675 img = self._image(image, max_width=300, max_height=300)
676 link = self._link(path=page, text=img)
678 content.append(' <tr>')
680 ' <td style="text-align: center;">',
685 if column == self._columns:
686 content.append(' </tr>')
690 content.append(' </tr>')
691 content.append('</table>')
694 def _directory(self, path, page=0, stream=None):
695 LOG.debug('directory page {} {}'.format(path, page))
696 images = list(self._images(path))
697 images_per_page = self._rows * self._columns
698 pages = int(_math.ceil(float(len(images)) / images_per_page)) or 1
699 if page < 0 or page >= pages:
703 'page out of bounds for this gallery 0 <= {:d} < {:d}'
704 ).format(page, pages))
705 first_image = images_per_page * page
706 images = images[first_image:first_image+images_per_page]
708 content.extend(self.header)
709 content.extend(self._directory_header(path))
710 nav = self._directory_page_navigation(path, page=page, pages=pages)
712 content.extend(self._directory_subdirs(path))
713 content.extend(self._directory_images(path, images=images))
715 content.extend(self.footer)
716 self._response(content='\n'.join(content), stream=stream)
718 def _page(self, path, stream=None):
719 LOG.debug('image page {}'.format(path))
720 gallery = _os_path.dirname(path)
721 images = list(self._images(gallery))
722 images_per_page = self._rows * self._columns
723 i = images.index(path)
724 page = i // images_per_page
725 gallery_page = '{}/'.format(gallery)
726 prev_page = self._image_page(images[i - 1])
727 next_page = self._image_page(images[(i + 1) % len(images)])
729 content.extend(self.header)
731 '<div style="text-align: center;">',
733 self._link(path=prev_page, text='previous'),
735 path=gallery_page, query={'pp': page+1}, text='all'),
736 self._link(path=next_page, text='next'),
739 content.extend(self._captioned_video(path))
740 content.append('</div>')
741 content.extend(self.footer)
742 self._response(content='\n'.join(content), stream=stream)
745 def serve_cgi(server):
752 #cgitb.enable(display=0, logdir="/tmp/")
753 data = cgi.FieldStorage()
754 page = server.page_from_query(
755 query={key: data[key].getlist() for key in data.keys()})
758 if isinstance(p, list):
761 stream = sys.stdout # Python 2
762 if hasattr(stream, 'buffer'): # Python 3
763 stream = sys.stdout.buffer
764 server.serve(url=url, page=page, stream=stream)
766 def serve_scgi(server, host='localhost', port=4000):
768 import scgi.scgi_server
770 class GalleryHandler(scgi.scgi_server.SCGIHandler):
771 def produce(self, env, bodysize, input, output):
772 #LOG.info(HTTP_USER_AGENT REQUEST_METHOD REMOTE_ADDR REQUEST_URI
773 url = env.get('DOCUMENT_URI', None)
774 page = server.page_from_query(
775 query_string=env.get('QUERY_STRING', ''))
778 url = server.relative_url(url=url)
779 except HTTPError as e:
781 server._error(e.status, content=e.content, stream=output)
782 except ProcessingComplete:
785 server.serve(url=url, page=page, stream=output)
787 s = scgi.scgi_server.SCGIServer(
788 handler_class=GalleryHandler, host=host, port=port)
789 LOG.info('serving SCGI on {}:{}'.format(host, port))
792 def serve_wsgi(server, host='localhost', port=4000):
794 import wsgiref.simple_server
796 server._write_http_headers = False
798 def app(environ, start_response):
799 url = environ.get('PATH_INFO', None)
800 page = server.page_from_query(
801 query_string=environ.get('QUERY_STRING', ''))
804 stream = io.BytesIO()
807 url = server.relative_url(url=url)
808 except HTTPError as e:
810 server._error(e.status, content=e.content, stream=stream)
811 except ProcessingComplete as e:
814 e = server.serve(url=url, page=page, stream=stream)
816 output = stream.getvalue()
817 status = headers.pop('Status')
818 start_response(status, list(headers.items()))
821 wsgi = wsgiref.simple_server.make_server(host=host, port=port, app=app)
822 LOG.info('serving WSGI on {}:{}'.format(host, port))
826 if __name__ == '__main__':
827 import argparse as _argparse
829 parser = _argparse.ArgumentParser(
830 description=__doc__, version=__version__,
831 formatter_class=_argparse.RawDescriptionHelpFormatter)
833 '--mode', default='cgi', choices=['cgi', 'scgi', 'wsgi'],
834 help='Server mode (defaults to CGI)')
836 '--port', default=4000, type=int,
837 help='Port to listen to (if runing as a SCGI server)')
839 '--base-path', default='.',
840 help='Path to the root gallery source')
842 '--base-url', default='/',
843 help='URL for the root gallery source')
845 '--shared-path', default=None,
846 help=('Optional path to the shared directory containing '
847 '`header.shtml` and `footer.shtml`'))
849 '--cache-path', default='/tmp/gallery-cache',
850 help='Path to the thumbnail and movie cache directory')
852 args = parser.parse_args()
854 s = CGIGalleryServer(
855 base_path=args.base_path, base_url=args.base_url,
856 cache_path=args.cache_path)
858 shared = args.shared_path
859 s.header = [open(_os_path.join(shared, 'header.shtml'), 'r').read()]
860 s.footer = [open(_os_path.join(shared, 'footer.shtml'), 'r').read()]
862 if args.mode == 'scgi':
863 serve_scgi(server=s, port=args.port)
864 if args.mode == 'wsgi':
865 serve_wsgi(server=s, port=args.port)