|
|
|
""" Miscellaneous utilities. """
|
|
|
|
|
|
|
|
import os
|
|
|
|
import shutil
|
|
|
|
import io
|
|
|
|
import tempfile
|
|
|
|
import pathlib
|
|
|
|
import math
|
|
|
|
from collections import defaultdict
|
|
|
|
|
|
|
|
from flask import request, Response, send_file
|
|
|
|
from PIL import Image, ImageChops
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
class MsgStore:
|
|
|
|
"""Store different types of messages."""
|
|
|
|
|
|
|
|
def __init__( self ):
|
|
|
|
self._msgs = None
|
|
|
|
self.reset()
|
|
|
|
|
|
|
|
def reset( self ):
|
|
|
|
"""Reset the MsgStore."""
|
|
|
|
self._msgs = defaultdict( list )
|
|
|
|
|
|
|
|
def info( self, msg, *args, **kwargs ):
|
|
|
|
"""Add an informational message."""
|
|
|
|
self._add_msg( "info", msg, *args, **kwargs )
|
|
|
|
|
|
|
|
def warning( self, msg, *args, **kwargs ):
|
|
|
|
"""Add a warning message."""
|
|
|
|
self._add_msg( "warning", msg, *args, **kwargs )
|
|
|
|
|
|
|
|
def error( self, msg, *args, **kwargs ):
|
|
|
|
"""Add an error message."""
|
|
|
|
self._add_msg( "error", msg, *args, **kwargs )
|
|
|
|
|
|
|
|
def get_msgs( self, msg_type ):
|
|
|
|
"""Get stored messages."""
|
|
|
|
return self._msgs[ msg_type ]
|
|
|
|
|
|
|
|
def _add_msg( self, msg_type, msg, *args, **kwargs ):
|
|
|
|
"""Add a message to the store."""
|
|
|
|
logger = kwargs.pop( "logger", None )
|
|
|
|
if args or kwargs:
|
|
|
|
# NOTE: We only format the message if there are any parameters, to handle the case
|
|
|
|
# where the caller passed us a single string that happens to contain a {.
|
|
|
|
msg = msg.format( *args, **kwargs )
|
|
|
|
self._msgs[ msg_type ].append( msg )
|
|
|
|
if logger:
|
|
|
|
func = getattr( logger, "warn" if msg_type == "warning" else msg_type )
|
|
|
|
func( msg )
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
class TempFile:
|
|
|
|
"""Manage a temp file that can be closed while it's still being used."""
|
|
|
|
|
|
|
|
def __init__( self, mode="wb", extn=None, encoding=None ):
|
|
|
|
self.mode = mode
|
|
|
|
self.extn = extn
|
|
|
|
self.encoding = encoding
|
|
|
|
self.temp_file = None
|
|
|
|
self.name = None
|
|
|
|
|
|
|
|
def open( self ):
|
|
|
|
"""Allocate a temp file."""
|
|
|
|
if self.encoding:
|
|
|
|
encoding = self.encoding
|
|
|
|
else:
|
|
|
|
encoding = "utf-8" if "b" not in self.mode else None
|
|
|
|
assert self.temp_file is None
|
|
|
|
self.temp_file = tempfile.NamedTemporaryFile(
|
|
|
|
mode = self.mode,
|
|
|
|
encoding = encoding,
|
|
|
|
suffix = self.extn,
|
|
|
|
delete = False
|
|
|
|
)
|
|
|
|
self.name = self.temp_file.name
|
|
|
|
|
|
|
|
def close( self, delete ):
|
|
|
|
"""Close the temp file."""
|
|
|
|
self.temp_file.close()
|
|
|
|
if delete:
|
|
|
|
os.unlink( self.temp_file.name )
|
|
|
|
|
|
|
|
def write( self, data ):
|
|
|
|
"""Write data to the temp file."""
|
|
|
|
self.temp_file.write( data )
|
|
|
|
|
|
|
|
def save_copy( self, fname, logger, caption ):
|
|
|
|
"""Make a copy of the temp file (for debugging porpoises)."""
|
|
|
|
if not fname:
|
|
|
|
return
|
|
|
|
logger.debug( "Saving a copy of the %s: %s", caption, fname )
|
|
|
|
shutil.copyfile( self.temp_file.name, fname )
|
|
|
|
|
|
|
|
def __enter__( self ):
|
|
|
|
"""Enter the context manager."""
|
|
|
|
self.open()
|
|
|
|
return self
|
|
|
|
|
|
|
|
def __exit__( self, exc_type, exc_val, exc_tb ):
|
|
|
|
"""Exit the context manager."""
|
|
|
|
self.close( delete=True )
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
def resize_image_response( resp, default_width=None, default_height=None, default_scaling=None ):
|
|
|
|
"""Resize an image that will be returned as a Flask response."""
|
|
|
|
|
|
|
|
assert isinstance( resp, Response )
|
|
|
|
|
|
|
|
def get_image():
|
|
|
|
"""Get the the image from the Flask response that was passed in."""
|
|
|
|
resp.direct_passthrough = False
|
|
|
|
buf = io.BytesIO()
|
|
|
|
buf.write( resp.get_data() )
|
|
|
|
buf.seek( 0 )
|
|
|
|
return Image.open( buf )
|
|
|
|
|
|
|
|
# check if the caller specified a width and/or height
|
|
|
|
width = request.args.get( "width", default_width )
|
|
|
|
height = request.args.get( "height", default_height )
|
|
|
|
if width and height:
|
|
|
|
# width and height were specified, just use them as-is
|
|
|
|
img = get_image()
|
|
|
|
width = int( width )
|
|
|
|
height = int( height )
|
|
|
|
elif width and not height:
|
|
|
|
# width only, calculate the height
|
|
|
|
img = get_image()
|
|
|
|
aspect_ratio = float(img.size[0]) / float(img.size[1])
|
|
|
|
height = int(width) / aspect_ratio
|
|
|
|
elif not width and height:
|
|
|
|
# height only, calculate the width
|
|
|
|
img = get_image()
|
|
|
|
aspect_ratio = float(img.size[0]) / float(img.size[1])
|
|
|
|
width = int(height) * aspect_ratio
|
|
|
|
elif not width and not height:
|
|
|
|
# check if the caller specified a scaling factor
|
|
|
|
scaling = request.args.get( "scaling", default_scaling )
|
|
|
|
if scaling and scaling != 100:
|
|
|
|
img = get_image()
|
|
|
|
width = img.size[0] * float(scaling)/100
|
|
|
|
height = img.size[1] * float(scaling)/100
|
|
|
|
|
|
|
|
# check if we need to resize the image
|
|
|
|
if width or height:
|
|
|
|
assert width and height
|
|
|
|
# yup - make it so
|
|
|
|
img = img.resize( (int(width),int(height)), Image.ANTIALIAS )
|
|
|
|
buf = io.BytesIO()
|
|
|
|
img.save( buf, format="PNG" )
|
|
|
|
buf.seek( 0 )
|
|
|
|
return send_file( buf, mimetype="image/png" )
|
|
|
|
else:
|
|
|
|
# nope - return the image as-is
|
|
|
|
return resp
|
|
|
|
|
|
|
|
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
|
|
|
|
def trim_image( img ):
|
|
|
|
"""Trim whitespace from an image."""
|
|
|
|
if isinstance( img, str ):
|
|
|
|
img = Image.open( img )
|
|
|
|
# trim the screenshot (nb: we assume a white background)
|
|
|
|
bgd = Image.new( img.mode, img.size, (255,255,255,255) )
|
|
|
|
diff = ImageChops.difference( img, bgd )
|
|
|
|
bbox = diff.getbbox()
|
|
|
|
return img.crop( bbox )
|
|
|
|
|
|
|
|
def get_image_data( img, **kwargs ):
|
|
|
|
"""Get the data from a Pillow image."""
|
|
|
|
buf = io.BytesIO()
|
|
|
|
img.save( buf, format=kwargs.pop("format","PNG"), **kwargs )
|
|
|
|
buf.seek( 0 )
|
|
|
|
return buf.read()
|
|
|
|
|
|
|
|
def remove_alpha_from_image( img ):
|
|
|
|
"""Remove the alpha channel from an image."""
|
|
|
|
img2 = Image.new( "RGB", img.size, "WHITE" )
|
|
|
|
img2.paste( img, (0,0), img )
|
|
|
|
return img2
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
def change_extn( fname, extn ):
|
|
|
|
"""Change a filename's extension."""
|
|
|
|
return pathlib.Path( fname ).with_suffix( extn )
|
|
|
|
|
|
|
|
def is_image_file( fname ):
|
|
|
|
"""Check if a file is an image."""
|
|
|
|
if fname.startswith( "." ):
|
|
|
|
extn = fname
|
|
|
|
else:
|
|
|
|
extn = os.path.splitext( fname )[1]
|
|
|
|
return extn.lower() in (".png",".jpg",".jpeg",".gif")
|
|
|
|
|
|
|
|
def is_empty_file( fname ):
|
|
|
|
"""Check if a file is empty."""
|
|
|
|
return os.stat( fname ).st_size == 0
|
|
|
|
|
|
|
|
def parse_int( val, default=None ):
|
|
|
|
"""Parse an integer."""
|
|
|
|
try:
|
|
|
|
return int( val )
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
return default
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
def friendly_fractions( val, postfix=None, postfix2=None ):
|
|
|
|
"""Convert decimal values to more friendly fractions."""
|
|
|
|
if val is None:
|
|
|
|
return None
|
|
|
|
frac, val = math.modf( float( val ) )
|
|
|
|
if frac >= 0.875:
|
|
|
|
val = str( int(val) + 1 )
|
|
|
|
else:
|
|
|
|
val = str( int( val ) )
|
|
|
|
if frac >= 0.625:
|
|
|
|
val = val + "¾"
|
|
|
|
elif frac >= 0.375:
|
|
|
|
val = val + "½"
|
|
|
|
elif frac >= 0.125:
|
|
|
|
val = val + "¼"
|
|
|
|
if postfix:
|
|
|
|
if val == "0":
|
|
|
|
return "0 " + postfix2
|
|
|
|
elif val.startswith( "0&" ):
|
|
|
|
return val[1:] + " " + postfix
|
|
|
|
elif val == "1":
|
|
|
|
return "1 " + postfix
|
|
|
|
val = "{} {}".format( val, postfix2 )
|
|
|
|
return val[1:] if val.startswith( "0&" ) else val
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
_MONTH_NAMES = [ # nb: we assume English :-/
|
|
|
|
"January", "February", "March", "April", "May", "June",
|
|
|
|
"July", "August", "September", "October", "November", "December"
|
|
|
|
]
|
|
|
|
|
|
|
|
_DAY_OF_MONTH_POSTFIXES = { # nb: we assume English :-/
|
|
|
|
0: "th",
|
|
|
|
1: "st", 2: "nd", 3: "rd", 4: "th", 5: "th", 6: "th", 7: "th", 8: "th", 9: "th", 10: "th",
|
|
|
|
11: "th", 12: "th", 13: "th"
|
|
|
|
}
|
|
|
|
|
|
|
|
def get_month_name( month ):
|
|
|
|
"""Return a month name."""
|
|
|
|
return _MONTH_NAMES[ month-1 ]
|
|
|
|
|
|
|
|
def make_formatted_day_of_month( dom ):
|
|
|
|
"""Generate a formatted day of the month."""
|
|
|
|
if dom in _DAY_OF_MONTH_POSTFIXES:
|
|
|
|
return str(dom) + _DAY_OF_MONTH_POSTFIXES[ dom ]
|
|
|
|
else:
|
|
|
|
return str(dom) + _DAY_OF_MONTH_POSTFIXES[ dom % 10 ]
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
class SimpleError( Exception ):
|
|
|
|
"""Represents a simple error that doesn't require a stack trace (e.g. bad configuration)."""
|
|
|
|
pass
|