Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
tree: 9c684fa94a
Fetching contributors…

Octocat-spinner-32-eaf2f5

Cannot retrieve contributors at this time

file 354 lines (288 sloc) 11.187 kb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
from errno import EEXIST
from ectyper.magick import ImageMagick, is_remote
import logging
import os
from random import randint
from time import time
from tornado.web import RequestHandler, asynchronous, HTTPError

__all__ = ["ImageHandler", "CachingImageHandler", "FileCachingImageHandler"]

logger = logging.getLogger("ectyper")

class ImageHandler(RequestHandler):
    """
Base handler class that provides file and transform
operations common to all available image types
"""

    IMAGE_MAGICK_CLASS = ImageMagick

    def __init__(self, *args, **kwargs):
        super(ImageHandler, self).__init__(*args, **kwargs)
        self.magick = None

    def handler(self, *args):
        """
Primary entry point for your code. Override this method
and process the request as necessary.
"""
        raise NotImplementedError()

    @asynchronous
    def get(self, *args):
        ""
        self.calculate_options()
        self.handler(*args)

    def parse_size(self, size):
        """
Parses a string 'NxM' into a 2-tuple. If either N or M parses into
a float, it's rounded to the nearest int. If either value doesn't
parse properly or the string is malformed, None is returned.
"""
        if not size:
            return None

        try:
            size = map(lambda x: int(round(float(x))), size.split("x", 1))
        except ValueError:
            return None

        if len(size) == 2:
            return (size[0], size[1])

        return None

    def calculate_options(self):
        """
Builds an ImageMagick object according to the given parameters.
By default it supports the following params:

&size=NxM
Resize the source image to N pixels wide and M pixels high.

&maintain_ratio=1
Maintain aspect ratio when resizing (ignored if size is not
provided).

&reflection_height=N
Flip the image upside down and apply a gradient to mimic a
reflected image. reflection_alpha_top and reflection_alpha_bottom
can be used to set the gradient parameters.

&reflection_alpha_top=N
The top value to use when generating the gradient for
reflection_height, ignored if that parameter is not set. Should be
between 0 and 1. Defaults to 1.

&reflection_alpha_bottom=N
The bottom value to use when generating the gradient for
reflection_height, ignored if that parameter is not set. Should be
between 0 and 1. Defaults to 0.

&format=(jpeg|png|png16)
Format to convert the image into. Defaults to jpeg.
png16 is 24-bit png pre-dithered for 16-bit (RGB555) screens.
"""

        # Already calculated options, bail.
        if self.magick:
            return

        magick = self.IMAGE_MAGICK_CLASS()

        size = self.parse_size(self.get_argument("size", None))
        reflection_height = self.get_argument("reflection_height", None)

        # size=&maintain_ratio=
        if size:
            (w, h) = size
            maintain_ratio = self.get_argument("maintain_ratio", 0)
            magick.resize(w, h, int(maintain_ratio) == 1)
            if not reflection_height:
                magick.constrain(w, h)

        # reflection_height=&reflection_alpha_top=&reflection_alpha_bottom=
        if reflection_height:
            top = self.get_argument("reflection_alpha_top", 1)
            bottom = self.get_argument("reflection_alpha_bottom", 0)
            try:
                reflection_height = int(reflection_height)
                top = max(0.0, min(1.0, float(top)))
                bottom = max(0.0, min(1.0, float(bottom)))
            except:
                reflection_height = None
                top = 1.0
                bottom = 0.0

            if reflection_height:
                magick.reflect(reflection_height, top, bottom)

        magick.format = magick.JPEG
        format_param = self.get_argument("format", "").lower()
        if format_param[0:3] == "png":
            magick.format = magick.PNG
            if format_param == "png16":
                magick.rgb555_dither()

        self.magick = magick

    def set_content_type(self):
        """
Sets the Content-Type of the request to the mime-type of the image
according to the calculated ImageMagick parameters.
"""
        assert self.magick
        self.set_header("Content-Type", self.magick.get_mime_type())

    def convert_image(self, source):
        """
Takes a local path or URL and processes it through ImageMagick convert.
The result is written to the response (via self.write) and also handles
finishing the request. Raises a 404 error if the file is local and
doesn't exist, or if source is None.
"""
        assert self.magick

        logger.debug("converting %s" % source)
        if not source or (not is_remote(source) and not os.path.isfile(source)):
            raise HTTPError(404)

        self.set_content_type()
        self.magick.convert(source,
            chunk_ready=self.on_conv_chunk_ready,
            complete=self.on_conv_complete,
            error=self.on_conv_error)

    def on_conv_error(self):
        """
On conversion error, raise a 500 Server Error and log.
"""
        logger.error("Conversion failed for %s" % self.request.uri)
        raise HTTPError(500)

    def on_conv_chunk_ready(self, chunk):
        """
When a chunk of the converted image is ready, this callback is
initiated with the chunk that was just read.
"""
        logger.debug("read %d bytes" % len(chunk))
        self.write(chunk)

    def on_conv_complete(self):
        """
Once the image is fully converted and all data is read, this callback
will be initiated.
"""
        self.finish()

class CachingImageHandler(ImageHandler):
    """
ImageHandler that caches requests as necessary. You should override the
get_cache_name, on_cache_hit and on_cache_write methods.
"""
    @asynchronous
    def get(self, *args):
        self.calculate_options()
        if self.is_cached():
            self.set_content_type()
            self.on_cache_hit()
            self.finish()
        else:
            self.on_cache_miss()
            self.handler(*args)

    def on_conv_chunk_ready(self, chunk):
        """
Call into write handler on chunk ready.
"""
        super(CachingImageHandler, self).on_conv_chunk_ready(chunk)
        self.on_cache_write(chunk)

    def on_conv_complete(self):
        """
Hook our cache write complete on conversion complete.
"""
        super(CachingImageHandler, self).on_conv_complete()
        self.on_cache_write_complete()

    def is_cached(self):
        """
Return True if this request is already cached.
"""
        raise NotImplementedError()

    def on_cache_hit(self):
        """
Called if is_cached() returns True. The Content-Type header will be set
and self.finish() will be called immediately after this method returns.
"""
        raise NotImplementedError()

    def on_cache_miss(self):
        """
Called if is_cached() returns False, right before self.handler is
called.
"""
        raise NotImplementedError()

    def on_cache_write(self, chunk):
        """
Writes the given chunk to cache.
"""
        raise NotImplementedError()

    def on_cache_write_complete(self):
        """
Closes cache store for the current entry, if required.
"""
        pass

class FileCachingImageHandler(CachingImageHandler):
    """
Image handler that caches files on disk according to the filter chain defined
by the query.
"""

    CACHE_PATH = '/tmp'
    CREATE_MODE = 0755

    def __init__(self, *args, **kwargs):
        super(FileCachingImageHandler, self).__init__(*args, **kwargs)
        self.cache_fd = None
        self.cacheable = True
        self.identifier = None
        self.write_path = None
        self.final_path = None
        self.wrote_bytes = 0

    def is_cached(self):
        (fname, fullpath) = self.get_cache_name()

        result = None
        try:
            result = os.stat(fullpath)
        except OSError:
            result = None

        return result and result.st_size > 0

    def on_cache_hit(self):
        fullpath = self.get_cache_name()[1]
        if os.path.isfile(fullpath):
            fh = open(fullpath)
            self.write(fh.read())
            fh.close()
        else:
            raise HTTPError(404)

    def get_cache_name(self):
        # Build filename from filter chain
        filename = "base"
        if len(self.magick.filters) > 0:
            filename = "+".join(self.magick.filters)
        if self.identifier:
            filename += "%s-" % self.identifier
        filename += ".%s" % self.magick.format

        # Build /(request.path)/(filename)
        relpath = os.path.join('/', self.request.path, filename)

        # Normalize double slashes and dot notation as necessary
        relpath = os.path.normpath(relpath)

        # Strip leading slash
        relpath = relpath.lstrip('/')

        # Generate full path on disk
        fullpath = os.path.realpath(os.path.join(self.CACHE_PATH, relpath))

        return (relpath, fullpath)

    def on_cache_miss(self):
        pass

    def on_cache_write(self, chunk):
        if not self.cacheable:
            return

        if not self.cache_fd:
            self.final_path = self.get_cache_name()[1]

            # Generate a temporary write path
            self.write_path = "%s.cache.%d.%d" % (
                self.final_path, time(), randint(0, 10000))

            # Open the cache file for writing if the final and write paths do not
            # yet exist
            if not os.path.exists(self.final_path) and \
                    not os.path.exists(self.write_path):
                dname = os.path.dirname(self.write_path)

                # Create intermediate directories as needed
                try:
                    if not os.path.isdir(dname):
                        os.makedirs(dname, mode=self.CREATE_MODE)
                except OSError, e:
                    if e.errno != EEXIST:
                        raise

                self.cache_fd = open(self.write_path, "wb")

            else:
                self.cache_fd = None
                self.write_path = None
                self.final_path = None

        if self.cache_fd and chunk:
            self.cache_fd.write(chunk)
            self.wrote_bytes += len(chunk)

    def on_cache_write_complete(self):
        if self.cache_fd:
            self.cache_fd.close()
            self.cache_fd = None

        if self.write_path and self.final_path:
            # Rename for future hits, if we wrote bytes out,
            # otherwise kill the file.
            if self.wrote_bytes > 0:
                os.rename(self.write_path, self.final_path)
            else:
                os.remove(self.write_path)
Something went wrong with that request. Please try again.