view decoupage/web.py @ 115:a8613e4c51dc

call __init__ correctly
author Jeff Hammel <k0scist@gmail.com>
date Wed, 28 Aug 2024 08:30:14 -0700
parents 5099d45c7730
children
line wrap: on
line source

"""
decoupage: a dynamic file server
"""

# TODO:

# handle files with `#`s like like `#index.ini`
# -> http://k0s.org/portfolio/ideas/#index.ini#
#
# oops. Handle it better
# - either # is a magic hide character
# - or you urlescape that guy


import os
import sys

from collections import OrderedDict
from contenttransformer.app import FileTypeTransformer
from contenttransformer.app import transformers
from datetime import datetime

from genshi.builder import Markup
from genshi.template import TemplateLoader
from genshi.template.base import TemplateError
from genshi.template.base import TemplateSyntaxError
from genshi.template.loader import TemplateNotFound
from martini.config import ConfigMunger
from paste.fileapp import FileApp
from pkg_resources import iter_entry_points
from pkg_resources import load_entry_point
from pkg_resources import resource_filename
from webob import Request, Response, exc

from .formatters import formatters

transformers = transformers()

try:
    # python2
    string = (str, unicode)
except NameError:
    # python3
    string = (str,)


class FileSorter(object):
    def __init__(self, *keys):
        self.keys = keys
    def __call__(self, item):
        try:
            index = self.keys.index(item['name'])
        except ValueError:
            index = len(self.keys)
        return (index, item['name'].lower(), item['name'])


class Decoupage(object):

    ### class level variables
    defaults = { 'auto_reload': False,
                 'configuration': None,
                 'directory': None, # directory to serve
                 'cascade': True, # whether to cascade configuration
                 'template': 'index.html', # XXX see below
                 'template_directories': '', # list of directories to look for templates
                 'charset': 'utf-8', # content encoding for index.html files; -> `Content-Type: text/html; charset=ISO-8859-1`
                 'file_sorter': FileSorter
                 }

    def __init__(self, **kw):

        # set defaults from app configuration
        for key, default_value in self.defaults.items():

            value = kw.get(key, default_value)

            # handle non-string bools
            if isinstance(default_value, bool) and isinstance(value, string):
                value = {'true': True,
                         'false': False}[value.lower()]
                # TODO: error handling for bad strings

            setattr(self, key, value)


        # configure defaults
        assert self.directory, "Decoupage: directory not specified"
        self.directory = self.directory.rstrip(os.path.sep)
        assert os.path.isdir(self.directory), "'%s' is not a directory" % self.directory
        self.template_directories = self.template_directories.split() # no spaces in directory names, for now

        for directory in self.template_directories:
            assert os.path.isdir(directory), "Decoupage template directory %s does not exist!" % directory

        # static file server
        self.fileserver = FileApp

        # pluggable formats
        s = 'format.'
        _format_args = [ (i.split(s, 1)[-1], j) for i, j in kw.items()
                         if i.startswith(s) ]
        format_args = {}
        for i, j in _format_args:
            assert i.count('.') == 1, 'Illegal string or something'
            format_name, var_name = i.split('.')
            format_args.setdefault(format_name, {})[var_name] = j
        self.formats = {}
        for _format in iter_entry_points('decoupage.formats'):
            try:
                _cls = _format.load()
                _instance = _cls(self, **format_args.get(_format.name, {}))
            except Exception as e:
                # record the error, but persist
                sys.stderr.write("Couldn't load format: {}\n{}\n".format(_format, e))
                continue
            self.formats[_format.name] = _instance

        # pluggable index data formatters
        self.formatters = {}
        for formatter in iter_entry_points('decoupage.formatters'):
            try:
                _formatter = formatter.load()
                template_dir = resource_filename(formatter.module_name, 'templates')
                if template_dir not in self.template_directories and os.path.isdir(template_dir):
                    self.template_directories.append(template_dir)
            except Exception as e:
                # record the error, but persist
                sys.stderr.write("Couldn't load formatter: {}\n{}\n".format(formatter, e))
                continue
            self.formatters[formatter.name] = _formatter

        # template loader
        self.loader = TemplateLoader(self.template_directories,
                                     variable_lookup="lenient",
                                     auto_reload=self.auto_reload)


    ### methods dealing with HTTP

    def __call__(self, environ, start_response):
        """WSGI application"""

        # boilerplate: request and filename
        request = Request(environ)
        filename = request.path_info.strip('/')
        path = os.path.join(self.directory, filename)

        # check to see what we have to serve
        if os.path.exists(path):

            if os.path.isdir(path):
                # serve an index
                if request.path_info.endswith('/'):
                    res = self.get(request)
                else:
                    res = exc.HTTPMovedPermanently(add_slash=True)
                return res(environ, start_response)

            else:
                # serve a file
                conf = self.conf(request.path_info.rsplit('/',1)[0])
                if '/transformer' in conf:
                    args = [i.split('=', 1) for i in conf['/transformer'].split(',') if '=' in i]
                    kwargs = {}
                    for i in conf:
                        if i.startswith('/'):
                            name = i[1:]
                            if name in transformers:
                                kwargs[name] = dict([j.split('=', 1) for j in conf[i].split(',') if '=' in j])
                    fileserver = FileTypeTransformer(*args, **kwargs)
                else:
                    fileserver = self.fileserver

                fileserver = fileserver(path)
                return fileserver(environ, start_response)
        else:
            # file does not exist
            conf = self.conf('/')
            data = dict(request=request,
                        title="Not Found")
            template = self.loader.load('HTTPNotFound.html')
            body = template.generate(**data).render('html', doctype='html')
            response = Response(content_type='text/html', body=body, status=404)
            return response(environ, start_response)


    def get(self, request):
        """
        return response to a GET requst
        """

        # ensure a sane path
        path = request.path_info.strip('/')
        directory = os.path.join(self.directory, path)
        path = '/%s' % path

        # get the configuraton
        conf = self.conf(path)

        ### build data dictionary
        # TODO: separate these out into several formatters
        files = self.filedata(path, directory, conf)
        data = {'path': path, 'files': files, 'request': request}

        # add a function to get the path to files
        data['filepath'] = lambda *segments: os.path.join(*([directory] + list(segments)))

        # defaults
        data['directory'] = directory
        data['css'] = ()
        data['scripts'] = ()

        # apply formatters
        formatters = self.get_formatters(path)
        for formatter in formatters:
            formatter(request, data)

        # return an alternate format if specified
        # decoupage.formats should return a 2-tuple:
        # (content_type, body)
        if 'format' in request.GET:
            format_name = request.GET['format']
            if format_name in self.formats:
                _format = self.formats[format_name]
                content_type, body = _format(request, data)
                return Response(content_type=content_type, body=body)

        # render the template
        template = conf.get('/template')
        local_index = False
        if template is None:
            if 'index.html' in [ f['name'] for f in files ]:
                local_index = os.path.join(directory, 'index.html')
                template = local_index
            else:
                template = self.template
        else:
            if not os.path.isabs(template):
                _template = os.path.join(directory, template)
                if os.path.exists(_template):
                    template = _template
                else:
                    for _directory in self.template_directories:
                        if template in os.listdir(_directory):
                            break
                    else:
                        raise TemplateNotFound("template %s not found" % template, self.loader.search_path)

        # extend template `search_path` to include local directory
        self.loader.search_path.insert(0, directory)
        try:
            template = self.loader.load(template)
            res = template.generate(**data).render('html', doctype='html')
        except (TemplateError, TemplateSyntaxError, TemplateNotFound) as e:
            if local_index:
                print (repr(e))
                return self.fileserver(local_index)
            raise
        finally:
            self.loader.search_path.pop(0)

        # set charset if given
        kw = {}
        if self.charset:
            kw['charset'] = self.charset

        # return response
        return Response(content_type='text/html', body=res, **kw)


    ### internal methods

    def file_sort_key(filename, keys):
        try:
            index = keys.index(filename)
        except ValueError:
            index = len(keys)
        return (index, filename.lower(), filename)

    def filedata(self, path, directory, conf=None):
        conf = conf or OrderedDict()
        files = []

        # get data for files
        filenames = os.listdir(directory)
        for i in filenames:
            filepath = os.path.join(directory, i)
            filetype = 'file'
            if os.path.isdir(filepath):
                filetype = 'directory'
            try:
                modified = os.path.getmtime(filepath)
            except OSError:
                # the file (mysteriously) may not exist by this time(!)
                #  File "/home/jhammel/web/src/decoupage/decoupage/web.py", line 114, in __call__
                #    res = self.get(request)
                #  File "/home/jhammel/web/src/decoupage/decoupage/web.py", line 162, in get
                #    files = self.filedata(path, directory, conf)
                #  File "/home/jhammel/web/src/decoupage/decoupage/web.py", line 246, in filedata
                #    modified = os.path.getmtime(filepath)
                #  File "/home/jhammel/web/lib/python2.6/genericpath.py", line 54, in getmtime
                #    return os.stat(filename).st_mtime
                # OSError: [Errno 2] No such file or directory: '/home/jhammel/web/site/portfolio/ideas/.#index.ini'
                continue # wt{h,f}???

            modified = datetime.fromtimestamp(modified)
            data = {'path' : '%s/%s' % (path.rstrip('/'), i),
                    'name': i,
                    'modified': modified,
                    'type': filetype}
            if filetype == 'file':
                data['size'] =  os.path.getsize(filepath)
            files.append(data)

        for i in conf:

            if i in filenames or i.startswith('/'):
                continue
            # TODO: deal with other links in conf;
            # this actually doesn't work because the ':' is magical to .ini files
            if i.startswith('http://') or i.startswith('https://'):
                files.append({'path': i,
                              'name': i,
                              'type': link})

        # TODO: sort files
        files = sorted(files, key=self.file_sorter(*conf.keys()))

        # get the description
        for f in files:
            f['description'] = conf.get(f['name'], None)

        return files

    def conf(self, path, cascade=None):
        """returns configuration dictionary appropriate to a path"""
        if cascade is None:
            cascase = self.cascade

        directory = os.path.join(self.directory, path.strip('/'))
        if path.strip('/'):
            path_tuple = tuple(path.strip('/').split('/'))
        else:
            path_tuple = ()

        # return cached configuration
        if hasattr(self, '_conf') and path_tuple in self._conf:
            return self._conf[path_tuple]

        conf = OrderedDict()

        # local configuration
        ini_path = os.path.join(directory, 'index.ini')
        if os.path.exists(ini_path):
            try:
                _conf = ConfigMunger(ini_path).dict()
            except Exception as e:
                print(f"Error reading INI {ini_path}: {e}")
                raise
            if len(_conf) == 1:
                conf = _conf[list(_conf.keys())[0]].copy()

        # global configuration
        if not conf and self.configuration and os.path.exists(self.configuration):
            conf = ConfigMunger(self.configuration).dict().get('/%s' % path.rstrip('/'), {})

        # inherit and cascade configuration
        inherit_directory = None
        if '/inherit' in conf:
            inherit_directory = conf['/inherit']
        elif self.cascade and path_tuple:
            inherit_directory = '/%s' % '/'.join(path_tuple[:-1])
        if inherit_directory:
            parent_configuration = self.conf(inherit_directory)
            for key, value in parent_configuration.items():
                if key.startswith('/') and key not in conf:
                    conf[key] = value

        # cache configuration
        if not self.auto_reload:
            if not hasattr(self, '_conf'):
                self._conf = OrderedDict()
            self._conf[path_tuple] = conf

        return conf

    def get_formatters(self, path):
        """return formatters for a path"""
        retval = []
        conf = self.conf(path)
        # apply formatters
        # XXX this should be cached if not self.auto_reload
        if '/formatters' in conf:
            # ordered list of formatters to be applied first
            formatters = [ i for i in conf['/formatters'].split()
                           if i in self.formatters ]
        else:
            formatters = []
        for key in conf:
            if key.startswith('/'):
                key = key[1:]
                if key in self.formatters and key not in formatters:
                    formatters.append(key)
        for name in formatters:
            retval.append(self.formatters[name](conf.get('/%s' % name, '')))
        return retval