photolib/photoapp/daemon.py

744 lines
29 KiB
Python

import os
import math
import time
import signal
import logging
import argparse
import cherrypy
from collections import defaultdict
from urllib.parse import urlparse
from datetime import datetime, timedelta
from photoapp.thumbtool import ThumbGenerator
from photoapp.types import APPROOT, Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, mime2ext, \
regular_mimes, video_mimes, ftypes
from photoapp.dbsession import DatabaseSession
from photoapp.common import pwhash
from photoapp.api import PhotosApi, LibraryManager
from photoapp.dbutils import SAEnginePlugin, SATool, db, get_db_engine, date_format
from photoapp.utils import auth, require_auth, photoset_auth_filter, slugify, cherryparam, number_format
from photoapp.storage import uri_to_storage
from photoapp.webutils import validate_password, serve_thumbnail_placeholder
from jinja2 import Environment, FileSystemLoader, select_autoescape
from sqlalchemy import desc, asc, func, and_, or_
class PhotosWeb(object):
"""
Http root of the UI webserver
"""
def __init__(self, library, thumbtool, template_dir):
self.library = library
self.thumbtool = thumbtool
self.tpl = Environment(loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(['html', 'xml']))
self.tpl.filters.update(mime2ext=mime2ext,
basename=os.path.basename,
ceil=math.ceil,
statusstr=lambda x: str(x).split(".")[-1],
number_format=number_format)
self.thumb = ThumbnailView(self)
self.photo = PhotoView(self)
self.download = DownloadView(self)
self.date = DateView(self)
self.tag = TagView(self)
self.album = self.tag
self.search = SearchView(self)
def render(self, template, **kwargs):
"""
Render a template
"""
return self.tpl.get_template(template).render(**kwargs, **self.get_default_vars())
def get_default_vars(self):
"""
Return a dict containing variables expected to be on every page
"""
if auth():
tagq = db.query(Tag).order_by(Tag.name).all()
else:
# for anonymous users, all tags with at least one album / photo marked visible
tagq = db.query(Tag).join(TagItem).join(PhotoSet)
if not auth():
tagq = tagq.filter(PhotoSet.status == PhotoStatus.public)
tagq = tagq.order_by(Tag.name).all() # pragma: manual auth
ret = {
"all_tags": tagq,
"path": cherrypy.request.path_info,
"auth": auth(),
"PhotoStatus": PhotoStatus,
"ftypes": ftypes
}
return ret
@cherrypy.expose
def index(self):
"""
Home page - redirect to the photo feed
"""
raise cherrypy.HTTPRedirect('feed', 302)
@cherrypy.expose
def feed(self, page=0, pgsize=25):
"""
/feed - main photo feed - show photos sorted by date, newest first
"""
page, pgsize = int(page), int(pgsize)
total_sets = photoset_auth_filter(db.query(func.count(PhotoSet.id))).first()[0]
images = photoset_auth_filter(db.query(PhotoSet)).order_by(PhotoSet.date.desc()). \
offset(pgsize * page).limit(pgsize).all()
yield self.render("feed.html", images=[i for i in images], page=page, pgsize=int(pgsize), total_sets=total_sets)
@cherrypy.expose
def stats(self):
"""
/stats - show server statistics
"""
images = photoset_auth_filter(db.query(func.count(PhotoSet.uuid),
date_format('%Y', PhotoSet.date).label('year'),
date_format('%m', PhotoSet.date).label('month'))). \
group_by('year', 'month').order_by(desc('year'), desc('month')).all()
tsize = photoset_auth_filter(db.query(func.sum(Photo.size)).join(PhotoSet)).scalar() # pragma: manual auth
yield self.render("monthly.html", images=images, tsize=tsize)
@cherrypy.expose
def map(self, i=None, a=None, zoom=3):
"""
/map - show all photos on the a map. Passing $i will show a single photo, or passing $a will show photos under
the given tag.
TODO using so many coordinates is slow in the browser. dedupe them somehow.
"""
query = photoset_auth_filter(db.query(PhotoSet)).filter(PhotoSet.lat != 0, PhotoSet.lon != 0)
if a:
query = query.join(TagItem).join(Tag).filter(Tag.uuid == a)
if i:
query = query.filter(PhotoSet.uuid == i)
yield self.render("map.html", images=query.all(), zoom=int(zoom))
@cherrypy.expose
@require_auth
def create_tags(self, fromdate=None, uuid=None, tag=None, newtag=None, remove=None):
"""
/create_tags - tag multiple items selected by day of photo
:param fromdate: act upon photos taken on this day
:param uuid: act upon a single photo with this uuid
:param tag: target photos will have a tag specified by this uuid added
:param remove: target photos will have the tag specified by this uuid removed
:param newtag: new tag name to create
"""
def get_photos():
if fromdate:
dt = datetime.strptime(fromdate, "%Y-%m-%d")
dt_end = dt + timedelta(days=1)
photos = db.query(PhotoSet).filter(and_(PhotoSet.date >= dt,
PhotoSet.date < dt_end)).order_by(PhotoSet.date.desc())
num_photos = db.query(func.count(PhotoSet.id)). \
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).order_by(PhotoSet.date.desc()).scalar()
if uuid:
photos = db.query(PhotoSet).filter(PhotoSet.uuid == uuid)
num_photos = db.query(func.count(PhotoSet.id)).filter(PhotoSet.uuid == uuid).scalar()
return photos, num_photos
if remove:
rmtag = db.query(Tag).filter(Tag.uuid == remove).first()
photoq, _ = get_photos()
for photo in photoq:
db.query(TagItem).filter(TagItem.tag_id == rmtag.id, TagItem.set_id == photo.id).delete()
db.commit()
if newtag:
db.add(Tag(title=newtag.capitalize(), name=newtag, slug=slugify(newtag)))
db.commit()
photos, num_photos = get_photos()
if tag: # Create the tag on all the photos
tag = db.query(Tag).filter(Tag.uuid == tag).first()
for photo in photos.all():
if 0 == db.query(func.count(TagItem.id)).filter(TagItem.tag_id == tag.id,
TagItem.set_id == photo.id).scalar():
db.add(TagItem(tag_id=tag.id, set_id=photo.id))
db.commit()
alltags = db.query(Tag).order_by(Tag.name).all()
yield self.render("create_tags.html", images=photos, alltags=alltags,
num_photos=num_photos, fromdate=fromdate, uuid=uuid)
@cherrypy.expose
def login(self):
"""
/login - enable super features by logging into the app
"""
cherrypy.session['authed'] = cherrypy.request.login
dest = "/feed" if "Referer" not in cherrypy.request.headers \
else urlparse(cherrypy.request.headers["Referer"]).path
raise cherrypy.HTTPRedirect(dest, 302)
@cherrypy.expose
def logout(self):
"""
/logout
"""
cherrypy.session.clear()
dest = "/feed" if "Referer" not in cherrypy.request.headers \
else urlparse(cherrypy.request.headers["Referer"]).path
raise cherrypy.HTTPRedirect(dest, 302)
@cherrypy.expose
def error(self, status, message, traceback, version):
yield self.render("error.html", status=status, message=message, traceback=traceback)
@cherrypy.popargs('date')
class DateView(object):
"""
View all the photos shot on a given date
"""
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(self, date=None, page=0):
if date:
page = int(page)
pgsize = 100
dt = datetime.strptime(date, "%Y-%m-%d")
dt_end = dt + timedelta(days=1)
total_sets = photoset_auth_filter(db.query(func.count(PhotoSet.id))). \
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).first()[0]
images = photoset_auth_filter(db.query(PhotoSet)). \
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)). \
order_by(PhotoSet.date.desc()). \
offset(page * pgsize).limit(pgsize).all()
yield self.master.render("date.html", page=page, pgsize=pgsize, total_sets=total_sets,
images=[i for i in images], date=dt)
return
images = photoset_auth_filter(db.query(
func.count(PhotoSet.id),
date_format('%Y', PhotoSet.date).label('year'),
date_format('%m', PhotoSet.date).label('month'),
date_format('%d', PhotoSet.date).label('day'))). \
group_by('year', 'month', 'day').order_by(desc('year'), 'month', 'day').all()
yield self.master.render("dates.html", images=images)
@cherrypy.popargs('item_type', 'thumb_size', 'uuid')
class ThumbnailView(object):
"""
Generate and serve thumbnails on-demand
"""
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(self, item_type, thumb_size, uuid):
# TODO some lock around calls to this based on photoset uuid
# TODO it is currently arbitrary which jpg of many or which video of many becomes the thumb. Make it certain.
uuid = uuid.split(".")[0]
query = photoset_auth_filter(db.query(Photo).join(PhotoSet))
query = query.filter(Photo.set.has(uuid=uuid)) if item_type == "set" \
else query.filter(Photo.uuid == uuid) if item_type == "one" \
else None
assert query
formats = defaultdict(list)
for photo in query.all():
formats[photo.format].append(photo)
formats = dict(formats)
thumb_from = None
# prefer image files. If an image is available assume it is the target or intended to be the thumbnail.
imtypes = regular_mimes.intersection(set(formats.keys()))
if imtypes:
# prefer making thumbs from jpeg to avoid loading large raws
# jk we can't load raws anyway
thumb_jpegs = formats.get("image/jpeg")
if thumb_jpegs:
thumb_from = thumb_jpegs[0] # TODO if we're thumbing a set this is an arbitrary image picked
else:
t = imtypes.pop() # TODO arbitrary
thumb_from = formats[t][0] # TODO arbitrary
vtypes = video_mimes.intersection(set(formats.keys()))
if vtypes and not thumb_from:
t = vtypes.pop() # TODO arbitrary
thumb_from = formats[t][0] # TODO arbitrary
if not thumb_from: # no format we know how to make a thumbnail from was found
return serve_thumbnail_placeholder()
thumb_fobj = self.master.thumbtool.make_thumb(thumb_from, thumb_size)
if thumb_fobj:
return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg")
else: # thumb creation failed or in progress
return serve_thumbnail_placeholder(True)
@cherrypy.popargs('item_type', 'uuid')
class DownloadView(object):
"""
View original files or force-download them
"""
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(self, item_type, uuid, preview=False):
uuid = uuid.split(".")[0]
query = None if item_type == "set" \
else photoset_auth_filter(db.query(Photo).join(PhotoSet)).filter(Photo.uuid == uuid) if item_type == "one" \
else None # TODO set download query
item = query.first()
if not item:
raise cherrypy.HTTPError(404)
extra = {}
if not preview:
extra.update(disposition="attachement", name=item.fname)
return cherrypy.lib.static.serve_fileobj(self.master.library.storage.open(item.path, 'rb'),
content_type=item.format, **extra)
@cherrypy.popargs('uuid')
class PhotoView(object):
"""
View a single photo
"""
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(self, uuid):
# uuid = uuid.split(".")[0]
photo = photoset_auth_filter(db.query(PhotoSet)).filter(or_(PhotoSet.uuid == uuid,
PhotoSet.slug == uuid)).first()
if not photo:
raise cherrypy.HTTPError(404)
if photo.lat:
nearby_photos = photoset_auth_filter(db.query(PhotoSet)) \
.filter(PhotoSet.lat.isnot(None)) \
.filter(PhotoSet.lon.isnot(None)) \
.filter(PhotoSet.id != photo.id) \
.order_by(func.sqrt(func.pow(PhotoSet.lat - photo.lat, 2) +
func.pow(PhotoSet.lon - photo.lon, 2))) \
.limit(25).all()
else:
nearby_photos = []
before = photoset_auth_filter(db.query(PhotoSet)) \
.filter(PhotoSet.date > photo.date) \
.order_by(PhotoSet.date.asc()) \
.limit(13).all()
before.reverse()
recent_photos = before + \
photoset_auth_filter(db.query(PhotoSet)) \
.filter(PhotoSet.date <= photo.date) \
.order_by(PhotoSet.date.desc()) \
.limit(12).all()
yield self.master.render("photo.html", image=photo, nearby=nearby_photos, recent=recent_photos)
@cherrypy.expose
@require_auth
def op(self, uuid, op, title=None, description=None, offset=None):
"""
Modify a photo
:param op: operation to perform:
* "Make public":
* "Make private":
* "Save": update the photo's title, description, and date_offset fields
"""
photo = db.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
if op == "Make public":
photo.status = PhotoStatus.public
elif op == "Make private":
photo.status = PhotoStatus.private
elif op == "Save":
photo.title = title
photo.description = description
photo.slug = slugify(title) or None
photo.date_offset = int(offset) if offset else 0
db.commit()
raise cherrypy.HTTPRedirect('/photo/{}'.format(photo.slug or photo.uuid), 302)
@cherrypy.expose
@require_auth
def edit(self, uuid):
photo = photoset_auth_filter(db.query(PhotoSet)).filter(PhotoSet.uuid == uuid).first()
yield self.master.render("photo_edit.html", image=photo)
@cherrypy.popargs('uuid')
class TagView(object):
"""
View the photos associated with a single tag
"""
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(self, uuid, page=0):
page = int(page)
pgsize = 100
tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
numphotos = photoset_auth_filter(db.query(func.count(Tag.id)).join(TagItem).join(PhotoSet)). \
filter(Tag.id == tag.id).scalar()
photos = photoset_auth_filter(db.query(PhotoSet)).join(TagItem).join(Tag). \
filter(Tag.id == tag.id). \
order_by(PhotoSet.date.desc()). \
offset(page * pgsize). \
limit(pgsize).all()
yield self.master.render("album.html", tag=tag, images=photos,
total_items=numphotos, pgsize=pgsize, page=page)
@cherrypy.expose
@require_auth
def op(self, uuid, op, title=None, description=None):
"""
Perform some action on this tag
- Promote: label this tag an album
- Demote: label this tag as only a tag and not an album
- Delete: remove this tag
- Make all public: mark all photos under this tag as public
- Make all private: mark all photos under this tag as private
"""
tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first()
if op == "Demote to tag":
tag.is_album = 0
elif op == "Promote to album":
tag.is_album = 1
elif op == "Delete tag":
db.query(TagItem).filter(TagItem.tag_id == tag.id).delete()
db.delete(tag)
db.commit()
raise cherrypy.HTTPRedirect('/', 302)
elif op == "Make all public":
# TODO smarter query
for photo in db.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
photo.status = PhotoStatus.public
elif op == "Make all private":
# TODO smarter query
for photo in db.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.id == tag.id).all():
photo.status = PhotoStatus.private
elif op == "Save":
tag.title = title.capitalize()
tag.name = title
tag.description = description
tag.slug = slugify(title)
else:
raise Exception("Invalid op: '{}'".format(op))
db.commit()
raise cherrypy.HTTPRedirect('/tag/{}'.format(tag.slug or tag.uuid), 302)
@cherrypy.expose
@require_auth
def edit(self, uuid):
tag = db.query(Tag).filter(Tag.uuid == uuid).first()
yield self.master.render("tag_edit.html", tag=tag)
FMT_TIME_SEARCH = "%Y/%m/%d %H.%M.%S"
class SearchView(object):
"""
Search & operations view.
Search -> View targets -> Apply actions
"""
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(
self,
page=0,
pgsize=100,
include_tags=None,
exclude_tags=None,
untagged=False,
include_formats=None,
before=None,
after=None,
set_before=None,
set_after=None,
keywords_title=None,
keywords_description=None,
keywords_filename=None,
operation=None,
add_tag=None,
remove_tag=None,
):
page = int(page)
pgsize = int(pgsize)
before = set_before or before
after = set_after or after
before = datetime.strptime(before, FMT_TIME_SEARCH) if before else None
after = datetime.strptime(after, FMT_TIME_SEARCH) if after else None
include_tags = cherryparam(include_tags)
exclude_tags = cherryparam(exclude_tags)
add_tag = cherryparam(add_tag)
remove_tag = cherryparam(remove_tag)
include_formats = cherryparam(include_formats) or list(ftypes.keys())
untagged = untagged in frozenset([True, 1, "1", "yes"])
include_tag_ids = []
if include_tags:
include_tag_ids = [i[0] for i in db.query(Tag.id).filter(Tag.uuid.in_(include_tags)).all()]
exclude_tag_ids = []
if exclude_tags:
exclude_tag_ids = [i[0] for i in db.query(Tag.id).filter(Tag.uuid.in_(exclude_tags)).all()]
# build image search query
def build_query(base):
base = photoset_auth_filter(base)
if before:
base = base \
.filter(PhotoSet.date <= before)
if after:
base = base \
.filter(PhotoSet.date >= after)
if include_tag_ids or exclude_tag_ids:
base = base \
.join(TagItem) \
.join(Tag)
if include_tag_ids:
base = base.filter(Tag.id.in_(include_tag_ids))
if exclude_tag_ids:
base = base.filter(~PhotoSet.id.in_(
db.query(PhotoSet.id)
.join(TagItem)
.join(Tag)
.filter(Tag.id.in_(exclude_tag_ids))))
if untagged:
base = base \
.filter(~PhotoSet.id.in_(db.query(TagItem.set_id)))
if keywords_title:
base = base \
.filter(PhotoSet.title.like("%{}%".format(keywords_title)))
if keywords_description:
base = base \
.filter(PhotoSet.description.like("%{}%".format(keywords_description)))
if keywords_filename:
base = base \
.filter(PhotoSet.id.in_(db.query(Photo.set_id).filter(Photo.fname.like("%{}%".format(keywords_filename)))))
# TODO there are two filters that use a Photo subquery. They could probably be combined.
allowed_mimes = set().union(*[mime["mimes"] for ext, mime in ftypes.items() if ext in include_formats])
base = base.filter(PhotoSet.id.in_(
db.query(Photo.set_id).filter(Photo.format.in_(allowed_mimes))))
base = base \
.order_by(PhotoSet.date.desc())
return base
if add_tag:
# this is extremely inefficient but i can't be arsed stepping outside of sqlalchemy
# no replace into :-(
tag_ids = set([r[0] for r in db.query(Tag.id).filter(Tag.uuid.in_(add_tag)).all()])
if tag_ids:
for photo in build_query(db.query(PhotoSet)).all():
# get tag ids for all tags on the photo
has_tagids = set([
r[0] for r in db.query(Tag.id).join(TagItem).filter(TagItem.set_id == photo.id).all()
])
# calculate which tags the image misses
new_tags = tag_ids - has_tagids
# add each new tag to the image
for new_tagid in new_tags:
db.add(TagItem(tag_id=new_tagid, set_id=photo.id))
db.commit()
if remove_tag:
# this is not as bad as add_tag but could be better
tag_ids = [r[0] for r in db.query(Tag.id).filter(Tag.uuid.in_(remove_tag)).all()]
photo_ids = [r[0] for r in build_query(db.query(PhotoSet.id)).all()]
if tag_ids and photo_ids:
db.query(TagItem) \
.filter(TagItem.tag_id.in_(tag_ids)) \
.filter(TagItem.set_id.in_(photo_ids)) \
.delete(synchronize_session=False)
db.commit()
# consequence of synchronize_session=False above
# or is it Fine since we don't re-use any orm-mapped objects from above?
db.expire_all()
query_start = time.time()
total_sets = build_query(db.query(func.count(PhotoSet.id))).scalar()
images = build_query(db.query(PhotoSet)).offset(pgsize * page).limit(pgsize).all()
query_duration = time.time() - query_start
yield self.master.render(
"search.html",
include_tags=include_tags,
exclude_tags=exclude_tags,
selecting_untagged=untagged,
include_formats=include_formats,
images=[i for i in images],
total_sets=total_sets,
page=page,
pgsize=int(pgsize),
operation=operation,
before=before.strftime(FMT_TIME_SEARCH) if before else "",
after=after.strftime(FMT_TIME_SEARCH) if after else "",
keywords_title=keywords_title or "",
keywords_description=keywords_description or "",
keywords_filename=keywords_filename or "",
now=datetime.now().strftime(FMT_TIME_SEARCH),
query_duration=query_duration,
)
def setup_webapp(database_url, library_url, cache_url, thumb_service_url, debug=False, max_upload=1024**3):
# Get database connection
engine = get_db_engine(database_url)
# Setup database in web framework
cherrypy.tools.db = SATool()
SAEnginePlugin(cherrypy.engine, engine).subscribe()
# Create various internal tools
library_storage = uri_to_storage(library_url)
library_manager = LibraryManager(library_storage)
thumbnail_tool = ThumbGenerator(library_manager, uri_to_storage(cache_url), thumb_service_url)
# Setup and mount web ui
tpl_dir = os.path.join(APPROOT, "templates") if not debug else "templates"
web = PhotosWeb(library_manager, thumbnail_tool, tpl_dir)
cherrypy.tree.mount(web, '/', {'/': {'tools.trailing_slash.on': False,
'tools.db.on': True,
'error_page.403': web.error,
'error_page.404': web.error},
'/static': {"tools.staticdir.on": True,
"tools.staticdir.dir": os.path.join(APPROOT, "styles/dist")
if not debug else os.path.abspath("styles/dist")},
'/thumb': {'tools.expires.on': True,
'tools.expires.secs': 7 * 86400},
'/login': {'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'photolib',
'tools.auth_basic.checkpassword': validate_password}})
# Setup and mount API
api = PhotosApi(library_manager)
cherrypy.tree.mount(api, '/api', {'/': {'tools.sessions.on': False,
'tools.trailing_slash.on': False,
'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'photolib',
'tools.auth_basic.checkpassword': validate_password,
'tools.db.on': True,
'request.dispatch': cherrypy.dispatch.MethodDispatcher()}})
# General config options
cherrypy.config.update({
'tools.sessions.storage_class': DatabaseSession,
'tools.sessions.on': True,
'tools.sessions.locking': 'explicit',
'tools.sessions.timeout': 525600,
'request.show_tracebacks': True,
'server.show_tracebacks': True,
'log.screen': False,
'server.max_request_body_size': max_upload
})
def main():
parser = argparse.ArgumentParser(description="Photod photo server")
parser.add_argument('-p', '--port', help="tcp port to listen on",
default=int(os.environ.get("PHOTOLIB_PORT", 8080)), type=int)
parser.add_argument('-l', '--library', default=os.environ.get("STORAGE_URL"), help="library path")
parser.add_argument('-c', '--cache', default=os.environ.get("CACHE_URL"), help="cache url")
parser.add_argument('-t', '--thumb-service', default=os.environ.get("THUMB_SERVICE_URL"), help="thumbnail service url")
# https://docs.sqlalchemy.org/en/13/core/engines.html
parser.add_argument('-s', '--database', help="sqlalchemy database connection uri",
default=os.environ.get("DATABASE_URL")),
parser.add_argument('--debug', action="store_true", help="enable development options")
tunables = parser.add_argument_group(title="tunables")
tunables.add_argument('--max-upload', help="maximum file upload size accepted in bytes",
default=1024**3, type=int)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
if not args.database:
parser.error("--database or DATABASE_URL is required")
if not args.library:
parser.error("--library or STORAGE_URL is required")
if not args.cache:
parser.error("--cache or CACHE_URL is required")
if not args.thumb_service:
logging.warning("THUMB_SERVICE_URL not set. Video thumbnails will be unavailable")
setup_webapp(
args.database,
args.library,
args.cache,
args.thumb_service,
debug=args.debug,
max_upload=args.max_upload
)
# Server config options
cherrypy.config.update({
'server.socket_port': args.port,
'server.thread_pool': 25,
'server.socket_host': '0.0.0.0',
'engine.autoreload.on': args.debug,
})
# Setup signal handling and run it.
def signal_handler(signum, stack):
logging.critical('Got sig {}, exiting...'.format(signum))
cherrypy.engine.exit()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
cherrypy.engine.start()
cherrypy.engine.block()
finally:
logging.info("API has shut down")
cherrypy.engine.exit()
if __name__ == '__main__':
main()