├── .gitignore ├── CHANGES.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── composer ├── __init__.py ├── command.py ├── filters.py ├── index.py ├── server.py └── writer.py ├── examples └── simple_mako │ ├── foo.mako │ ├── index.json │ ├── indexer.py │ ├── post.mako │ ├── posts │ └── 1.md │ └── static │ └── css │ └── style.css ├── optional.txt ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── test_index.py └── test_writer.py /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.pyc 3 | *.egg-info 4 | *.log 5 | /dist 6 | /build 7 | /docs/_build 8 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | Dev (2011-10-15) 5 | ++++++++++++++++ 6 | 7 | First working prototype. 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | This is the MIT license: http://www.opensource.org/licenses/mit-license.php 2 | 3 | Copyright 2011 Andrey Petrov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 9 | to whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or 12 | substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 16 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 17 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst CHANGES.rst LICENSE.txt optional.txt 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Compose dynamic templates and markup into a static website. 2 | 3 | Used to generate `shazow.net `_ (`source repo `_). 4 | 5 | Usage 6 | ===== 7 | 8 | Install 9 | ------- 10 | 11 | :: 12 | 13 | $ pip install https://github.com/shazow/composer/tarball/master 14 | $ pip install mako markdown2 # If you're using the built-in filters (optional) 15 | 16 | Auto-reloading server 17 | --------------------- 18 | 19 | Great for live preview and debugging. :: 20 | 21 | $ composer serve examples/simple_mako/index.json 22 | $ open http://localhost:8080/foo 23 | 24 | Static build 25 | ------------ 26 | 27 | :: 28 | 29 | $ composer build examples/simple_mako/index.json 30 | $ open build/foo/index.html 31 | 32 | 33 | Write your own index file 34 | ------------------------- 35 | 36 | We can write an indexer script which will generate our index file. :: 37 | 38 | #!/usr/bin/env python 39 | # indexer.py - Generate a Composer Index for my website. 40 | 41 | from composer.index import Index, Route, Static 42 | 43 | class SimpleIndex(Index): 44 | 45 | def _generate_static(self): 46 | yield Static('static', 'my_static_files') 47 | 48 | def _generate_routes(self): 49 | yield Route('foo', 'foo.mako', filters=['mako']) 50 | yield Route('post/1', 'posts/1.md', filters=['markdown', 'pygments']) 51 | 52 | 53 | if __name__ == '__main__': 54 | import json 55 | index = SimpleIndex() 56 | print json.dumps(index.to_dict(), indent=4) 57 | 58 | 59 | Now we run the script to generate the intermediate index file and run it. :: 60 | 61 | $ python indexer.py > index.json 62 | $ composer build index.json 63 | 64 | 65 | Or we can call the Index generator directly from Composer. This is great for 66 | really large and complex websites. :: 67 | 68 | $ composer build indexer:SimpleIndex 69 | 70 | 71 | Some examples of indexer scripts can be found here: 72 | 73 | - https://github.com/shazow/shazow.net/blob/master/indexer.py 74 | - https://github.com/shazow/composer/blob/master/examples/simple_mako/indexer.py 75 | 76 | 77 | Filters 78 | ------- 79 | 80 | A Filter is any callable factory which takes a string of content (and an 81 | optional Route object) and returns a modified string of content. When defining a 82 | Route, multiple filters can be chained together so that each filter's output 83 | will be the next filter's input. 84 | 85 | Here are two hypothetical implementations of a filter which appends a fixed 86 | footer string to the content: :: 87 | 88 | # myfilter.py 89 | 90 | # 1. Using a class: 91 | 92 | from composer.filters import Filter 93 | 94 | class FooterFilter(Filter): 95 | def __init__(self, footer=''): 96 | self.footer = footer 97 | 98 | def __call__(self, content, route=None): 99 | return content + '\n\n' + self.footer 100 | 101 | # 2. Same thing without using a class: 102 | 103 | def FooterFilter(footer=''): 104 | def _(content, route=None): 105 | return content + '\n\n' + footer 106 | return _ 107 | 108 | 109 | Now we can register our FooterFilter in our Index and use it in our Routes: :: 110 | 111 | # ... 112 | from myfilter import FooterFilter 113 | 114 | class SimpleIndex(Index): 115 | def _register_filters(self): 116 | self.register_filter(id='footer', 117 | filter_cls=FooterFilter, 118 | filter_kwargs={'footer': ''}) 119 | 120 | # ... 121 | 122 | 123 | Composer comes with a few builtin filters whose source should be easy to 124 | understand and extend. Default registered filters include: 125 | 126 | * ``mako``: `composer.filters.Mako `_ 127 | * ``markdown``: `composer.filters.Markdown `_ 128 | * ``rst``: `composer.filters.RestructuredText `_ 129 | * ``jinja2``: `composer.filters.Jinja2 `_ 130 | * ``pygments``: `composer.filters.Pygments `_ 131 | 132 | These filters are registered by default within 133 | ``Index._register_default_filters()``. There are also some builtin unregistered 134 | filters (such as 135 | `composer.filters.MakoContainer `_) 136 | which can be registered manually or extended. 137 | 138 | 139 | Components and Philosophy 140 | ========================= 141 | 142 | Composer builds static websites in two steps: First we index, then we compose. 143 | 144 | During indexing, we can output a ``index.json`` file which describes all the 145 | route URLs and how to render them. We feed the index into composer to generate 146 | static content--this can be done with the JSON file or the Index generator can 147 | be plugged in directly. 148 | 149 | This makes the composing step really simple because all the complex logic is 150 | separately assembled and can be flattened into a JSON file. 151 | 152 | Every complex setup seems to require a unique indexing step, so this allows you 153 | to customize just the piece that is applicable to you while letting Composer do 154 | what it does best. 155 | 156 | 157 | TODO 158 | ==== 159 | 160 | Roughly in priority-order: 161 | 162 | #. More filters 163 | #. More error handling and exceptions 164 | #. More Tests 165 | #. More documentation 166 | #. Optimize for large content bases: 167 | 168 | #. ``serve`` mode: Index routes for more efficient lookup. (Done) 169 | #. ``build`` mode: Add mtime-based checking to skip regenerating content that is already current. 170 | 171 | #. Scaffolds (with Makefile) 172 | #. Everything else 173 | #. Ponies 174 | 175 | 176 | License 177 | ======= 178 | 179 | The MIT License (see LICENSE.txt) 180 | -------------------------------------------------------------------------------- /composer/__init__.py: -------------------------------------------------------------------------------- 1 | # composer/__init__.py 2 | # Copyright 2011 Andrey Petrov 3 | # 4 | # This module is part of Composer and is released under 5 | # the MIT License: http://www.opensource.org/licenses/mit-license.php 6 | 7 | __author__ = 'Andrey Petrov (andrey.petrov@shazow.net)' 8 | __license__ = 'MIT' 9 | __version__ = 'dev' 10 | -------------------------------------------------------------------------------- /composer/command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env 2 | # composer/command.py 3 | # Copyright 2011 Andrey Petrov 4 | # 5 | # This module is part of Composer and is released under 6 | # the MIT License: http://www.opensource.org/licenses/mit-license.php 7 | 8 | 9 | import argparse 10 | import logging 11 | import os 12 | import json 13 | 14 | from .index import Index, import_object 15 | from .server import serve 16 | 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | def serve_command(index, **kw): 21 | serve(index, use_reloader=True, **kw) 22 | 23 | 24 | def build_command(index, build_path='build', clean=False): 25 | from distutils.dir_util import copy_tree, remove_tree 26 | from .writer import FileWriter 27 | 28 | if clean: 29 | log.info("Cleaning build path: %s", build_path) 30 | if os.path.exists(build_path): 31 | remove_tree(build_path) 32 | 33 | writer = FileWriter(index, build_path=build_path) 34 | 35 | for route in index.routes: 36 | writer(route.url) 37 | 38 | for static in index.static: 39 | log.info("Copying static url: %s", static.url) 40 | url_path = writer.materialize_url(static.url) 41 | copy_tree(static.file, url_path) 42 | 43 | 44 | def main(): 45 | parser = argparse.ArgumentParser(description=__doc__) 46 | 47 | parser.add_argument('-v', '--verbose', dest='verbose', action='count', help="Show DEBUG messages") 48 | 49 | command_parser = parser.add_subparsers(dest='command', title="Commands") 50 | 51 | # Server: 52 | 53 | serve_parser = command_parser.add_parser('serve', 54 | help="Preview the result in an auto-reloading and debug-enabled server (thanks to werkzeug).") 55 | 56 | serve_parser.add_argument('--host', dest='serve_host', metavar='HOST', default='localhost', 57 | help='(Default: %(default)s)') 58 | 59 | serve_parser.add_argument('--port', dest='serve_port', metavar='PORT', default=8080, type=int, 60 | help='(Default: %(default)s)') 61 | 62 | # Build: 63 | 64 | build_parser = command_parser.add_parser('build', 65 | help="Compose the index into a static build of the website, ready for deploying.") 66 | 67 | build_parser.add_argument('--build-path', dest='build_path', metavar="DIR", default='./build', 68 | help="Path to build into. (Default: %(default)s)") 69 | 70 | build_parser.add_argument('--clean', dest='clean', action='store_true', 71 | help='Delete contents of build path before building into it.') 72 | 73 | # Both: 74 | 75 | for p in [serve_parser, build_parser]: 76 | p.add_argument(dest='index_path', metavar="INDEX", 77 | help="") 78 | 79 | p.add_argument('--base-path', dest='base_path', metavar="DIR", 80 | help="Treat relative paths in the Index from this path. (Default: Path of json index file or cwd when index type is object)") 81 | 82 | p.add_argument('--index-type', dest='index_type', default='auto', 83 | choices=['auto', 'json', 'object'], 84 | help="How to interpret the INDEX value. 'auto' will try " 85 | "to guess, 'object' is a Python dotted object path " 86 | "like 'foo.bar:MyIndex'. (Default: %(default)s)") 87 | 88 | 89 | args = parser.parse_args() 90 | 91 | logging.basicConfig(format='%(asctime)s %(levelname)-5.5s [%(name)s] %(message)s', 92 | level=logging.INFO) 93 | 94 | if args.verbose > 0: 95 | log.setLevel(logging.DEBUG) 96 | 97 | # FIXME: Make this next part prettier. 98 | 99 | extra_files = [] 100 | 101 | if args.index_type == 'auto': 102 | log.debug("Unspecified index type, trying to guess based on value provided: %s", args.index_path) 103 | 104 | if args.index_type == 'json' or args.index_path.endswith('json'): # JSON 105 | log.debug("Loading index from json.") 106 | 107 | data = json.load(open(args.index_path)) 108 | base_path = os.path.dirname(args.index_path) 109 | index = Index.from_dict(data, base_path=base_path) 110 | extra_files += [base_path] 111 | 112 | elif args.index_type == 'object' or ':' in args.index_path: # Object 113 | log.debug("Loading index from Python object.") 114 | 115 | IndexCls = import_object(args.index_path) 116 | index = IndexCls(base_path=os.path.curdir) 117 | 118 | else: 119 | parser.error("Couldn't guess the type of your index file, try specifying `--index-type`.") 120 | 121 | 122 | if args.command == 'serve': 123 | serve_command(index, extra_files=[args.index_path], host=args.serve_host, port=args.serve_port) 124 | 125 | elif args.command == 'build': 126 | build_command(index, build_path=args.build_path, clean=args.clean) 127 | 128 | 129 | if __name__ == "__main__": 130 | main() 131 | -------------------------------------------------------------------------------- /composer/filters.py: -------------------------------------------------------------------------------- 1 | # composer/filters.py 2 | # Copyright 2011 Andrey Petrov 3 | # 4 | # This module is part of Composer and is released under 5 | # the MIT License: http://www.opensource.org/licenses/mit-license.php 6 | 7 | import os 8 | import re 9 | 10 | 11 | try: 12 | import markdown 13 | except ImportError: 14 | markdown = False 15 | 16 | try: 17 | import mako.lookup 18 | import mako.template 19 | except ImportError: 20 | mako = False 21 | 22 | try: 23 | import docutils.core 24 | except ImportError: 25 | docutils = False 26 | 27 | try: 28 | import jinja2 29 | except ImportError: 30 | jinja2 = False 31 | 32 | try: 33 | import pygments 34 | import pygments.lexers 35 | import pygments.formatters 36 | except ImportError: 37 | pygments = False 38 | 39 | 40 | __all__ = ['Filter', 41 | 'Mako', 'MakoContainer', 'Jinja2', 42 | 'RestructuredText', 'Markdown', 43 | 'Pygments'] 44 | 45 | 46 | _Default = object() 47 | 48 | 49 | class Filter(object): 50 | def __init__(self, index): 51 | self.index = index 52 | 53 | def __call__(self, content, route=None): 54 | """ 55 | :param content: 56 | String to filter, such as the contents of a file. 57 | 58 | :param route: 59 | Route object that contains a ``context`` property. 60 | """ 61 | return content 62 | 63 | 64 | class Markdown(Filter): 65 | def __init__(self, index, extensions=None, extension_configs=None): 66 | if not markdown: 67 | raise ImportError("Markdown filter requires the 'markdown' package to be installed.") 68 | 69 | super(Markdown, self).__init__(index) 70 | 71 | self.converter = markdown.Markdown(extensions=extensions or [], extension_configs=extension_configs or {}).convert 72 | 73 | def __call__(self, content, route=None): 74 | return self.converter(content) 75 | 76 | 77 | class Mako(Filter): 78 | def __init__(self, index, **template_kw): 79 | if not mako: 80 | raise ImportError("Mako filter requires the 'Mako' package to be installed.") 81 | 82 | super(Mako, self).__init__(index) 83 | 84 | kw = dict(input_encoding='utf-8', output_encoding='utf-8', encoding_errors='replace') 85 | kw.update(template_kw) 86 | 87 | self.template_kw = kw 88 | self.lookup = mako.lookup.TemplateLookup(**self.template_kw) 89 | 90 | def __call__(self, content, route=None): 91 | t = mako.template.Template(content, lookup=self.lookup, input_encoding='utf-8', output_encoding='utf-8', encoding_errors='replace') 92 | 93 | return str(t.render(index=self.index, route=route)) 94 | 95 | 96 | class MakoContainer(Mako): 97 | """ 98 | Similar to Mako except it loads the template from a given ``template`` and 99 | pipes the ``content`` into the ``body`` context variable. 100 | """ 101 | def __init__(self, index, template, **lookup_kw): 102 | if not mako: 103 | raise ImportError("MakoContainer filter requires the 'Mako' package to be installed.") 104 | 105 | super(MakoContainer, self).__init__(index, **lookup_kw) 106 | 107 | template = os.path.relpath(self.index.absolute_path(template), '.') 108 | 109 | self.template = self.lookup.get_template(template) 110 | 111 | def __call__(self, content, route=None): 112 | return str(self.template.render(index=self.index, body=content, route=route, cache_enabled=False)) 113 | 114 | 115 | class RestructuredText(Filter): 116 | # FIXME: This is untested and probably not Best Practices compliant. Someone 117 | # who seriously uses RST should make this filter better. 118 | 119 | def __init__(self, index, **rst_kw): 120 | if not docutils: 121 | raise ImportError("RestructuredText filter requires the 'docutils' package to be installed.") 122 | 123 | super(RestructuredText, self).__init__(index) 124 | 125 | self.rst_kw = rst_kw 126 | 127 | def __call__(self, content, route=None): 128 | return docutils.core.publish_string(content, **self.rst_kw) 129 | 130 | 131 | class Jinja2(Filter): 132 | # FIXME: This is untested and probably not Best Practices compliant. Someone 133 | # who seriously uses Jinja2 should make this filter better. 134 | 135 | # TODO: Make a Jinja2Container version of this Filter (similar to MakoContainer) 136 | 137 | def __init__(self, index, searchpaths=None): 138 | super(Jinja2, self).__init__(index) 139 | 140 | if not jinja2: 141 | raise ImportError("Jinja2 filter requires the 'Jinja2' package to be installed.") 142 | 143 | loaders = [] 144 | if searchpaths: 145 | loaders.append(jinja2.FileSystemLoader(searchpaths)) 146 | 147 | # TODO: Add support for more loaders? 148 | 149 | self.jinja_env = jinja2.Environment(loader=jinja2.ChoiceLoader(loaders)) 150 | 151 | def __call__(self, content, route=None): 152 | t = self.jinja_env.from_string(content) 153 | return t.render(index=self.index, route=route) 154 | 155 | 156 | class Pygments(Filter): 157 | """ 158 | Pygmentize Github-style fenced codeblocks. 159 | 160 | Based on code in http://misaka.61924.nl/ 161 | """ 162 | def __init__(self, index): 163 | if not pygments: 164 | raise ImportError("Pygments filter requires the 'pygments' package to be installed.") 165 | 166 | super(Pygments, self).__init__(index) 167 | 168 | self._re_codeblock = re.compile( 169 | r'(.*?)', 170 | re.IGNORECASE | re.DOTALL) 171 | 172 | def _unescape_html(self, html): 173 | html = html.replace('<', '<') 174 | html = html.replace('>', '>') 175 | html = html.replace('&', '&') 176 | return html.replace('"', '"') 177 | 178 | def _highlight_match(self, match): 179 | language, classname, code = match.groups() 180 | if (language or classname) is None: 181 | return match.group(0) 182 | return pygments.highlight(self._unescape_html(code), 183 | pygments.lexers.get_lexer_by_name(language or classname), 184 | pygments.formatters.HtmlFormatter()) 185 | 186 | def __call__(self, content, route=None): 187 | return str(self._re_codeblock.sub(self._highlight_match, content)) 188 | 189 | 190 | default_filters = { 191 | 'mako': Mako, 192 | 'markdown': Markdown, 193 | 'rst': RestructuredText, 194 | 'jinja2': Jinja2, 195 | 'pygments': Pygments, 196 | } 197 | -------------------------------------------------------------------------------- /composer/index.py: -------------------------------------------------------------------------------- 1 | # composer/index.py 2 | # Copyright 2011 Andrey Petrov 3 | # 4 | # This module is part of Composer and is released under 5 | # the MIT License: http://www.opensource.org/licenses/mit-license.php 6 | 7 | import logging 8 | import os 9 | import re 10 | import fnmatch 11 | 12 | from .filters import default_filters 13 | 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | ## 18 | 19 | # TODO: Use this to add route prev/current/next tracking? 20 | def iter_consume(i, num=0): 21 | if num < 0: 22 | for _ in xrange(-num): 23 | yield None 24 | 25 | elif num > 0: 26 | for _ in xrange(num): 27 | next(i) 28 | 29 | for o in i: 30 | yield o 31 | 32 | 33 | def import_object(path): 34 | module, obj = path.split(':', 1) 35 | o = __import__(module, fromlist=[obj]) 36 | return getattr(o, obj) 37 | 38 | ## 39 | 40 | class Route(object): 41 | """ 42 | :param url: 43 | Url of the route. 44 | 45 | :param file: 46 | Path of the while used to populate ``content`` if ``content` is None. 47 | 48 | :param filters: 49 | List of filter ids. 50 | 51 | :param context: 52 | Object passed into each filter. 53 | 54 | :param content: 55 | Fixed content to start the route with. If set, ignores the ``file`` 56 | param. 57 | """ 58 | def __init__(self, url=None, file=None, filters=None, context=None, content=None): 59 | self.url = url 60 | self.file = file 61 | self.filters = filters or [] 62 | self.context = context 63 | self.content = None 64 | 65 | 66 | class Static(object): 67 | def __init__(self, url, file): 68 | self.url = url 69 | self.file = file 70 | 71 | ## 72 | 73 | class Index(object): 74 | """ 75 | :param base_path: 76 | Base path that the rest of the file paths should be resolved in 77 | relation to. 78 | """ 79 | def __init__(self, base_path='', base_url='/'): 80 | self.base_path = os.path.abspath(base_path) 81 | self.base_url = '/' 82 | 83 | self.filters = {} 84 | self._filters_kwargs_cache = {} # For exporting 85 | self._register_default_filters() 86 | self._register_filters() 87 | 88 | self._route_cache = None 89 | 90 | def _register_default_filters(self): 91 | for filter_id, filter_cls in default_filters.iteritems(): 92 | try: 93 | self.register_filter(filter_id, filter_cls) 94 | log.debug("Registered default filter: %s", filter_id) 95 | except ImportError: 96 | log.debug("Skipping default filter due to missing dependency package: %s", filter_id) 97 | 98 | def _register_filters(self): 99 | "Stub." 100 | pass 101 | 102 | def register_filter(self, id, filter_cls, filter_kwargs=None): 103 | """ 104 | Instantiate the filter and register it under the given id for this 105 | Index. 106 | 107 | :param id: 108 | Id of filter used to reference it in routes. 109 | 110 | :param filter_cls: 111 | Class or callable which gets the Index instance as the first 112 | argument and ``\**kwargs`` after that. 113 | 114 | :param filter_kwargs: 115 | Dictionary of keyword arguments passed into ``filter_cls``. 116 | 117 | :returns: Instantiated filter object in respect to this Index. 118 | """ 119 | filter_kwargs = filter_kwargs or {} 120 | self.filters[id] = filter = filter_cls(self, **filter_kwargs) 121 | self._filters_kwargs_cache[id] = filter_kwargs 122 | return filter 123 | 124 | def _compile_globs(self, globs_or_regexps): 125 | if not globs_or_regexps: 126 | return [] 127 | 128 | return [re.compile(fnmatch.translate(s)) for s in globs_or_regexps if isinstance(s, basestring)] 129 | 130 | def _prune_paths(self, paths, exclude, include_only): 131 | for path in paths: 132 | if include_only and not any(r.match(path) for r in include_only): 133 | continue 134 | if exclude and any(r.match(path) for r in exclude): 135 | continue 136 | yield path 137 | 138 | def walk(self, start='.', exclude=None, include_only=None): 139 | """ 140 | Walk and yield relative paths from the Index's ``base_path``. 141 | 142 | :param exclude: 143 | List of string globs or regular expression objects to omit. 144 | 145 | :param include_only: 146 | List of string globs or regular expressions objects which one must 147 | match in order to be included. 148 | 149 | :param start: 150 | Path to start from relative to ``base_path`` 151 | """ 152 | # Compile globs into regexps 153 | exclude = self._compile_globs(exclude) 154 | include_only = self._compile_globs(include_only) 155 | 156 | start_path = self.absolute_path(start) 157 | 158 | for dirpath, dirnames, filenames in os.walk(start_path, followlinks=True): 159 | filenames = (self.relative_path(os.path.join(dirpath, f)) for f in filenames) 160 | filenames = self._prune_paths(filenames, exclude, include_only) 161 | for file in filenames: 162 | yield file 163 | 164 | def absolute_url(self, url): 165 | return os.path.join(self.base_url, url) 166 | 167 | def absolute_path(self, path, start='.'): 168 | """ 169 | Get the absolute path in respect to the Index base path. 170 | """ 171 | return os.path.join(self.base_path, start, path) 172 | 173 | def relative_path(self, path, start='.'): 174 | """ 175 | Get the relative path in respect to the Index base path + start. 176 | """ 177 | return os.path.relpath(path, os.path.join(self.base_path, start)) 178 | 179 | def _generate_routes(self): 180 | """ 181 | Yield Route objects. 182 | """ 183 | pass 184 | 185 | def _generate_static(self): 186 | """ 187 | Yield Static objects." 188 | """ 189 | pass 190 | 191 | def _refresh_route_cache(self): 192 | self._route_cache = {} 193 | 194 | for count, route in enumerate(self.routes): 195 | url = route.url.lstrip('/') 196 | log.debug("Refreshing route cache: /%s", url) 197 | self._route_cache[url] = route 198 | 199 | log.info("Cached %d routes.", count+1) 200 | 201 | def get_route(self, url): 202 | if self._route_cache is None: 203 | self._refresh_route_cache() 204 | 205 | return self._route_cache.get(url.lstrip('/')) 206 | 207 | @property 208 | def routes(self): 209 | return self._generate_routes() or () 210 | 211 | @property 212 | def static(self): 213 | return self._generate_static() or () 214 | 215 | @staticmethod 216 | def from_dict(d, **kw): 217 | index = Index(**kw) 218 | 219 | def _generate_routes(): 220 | for route_kw in d.get('routes', []): 221 | r = Route(**route_kw) 222 | r.file = index.absolute_path(r.file) 223 | yield r 224 | 225 | index._generate_routes = _generate_routes 226 | 227 | def _generate_static(): 228 | for static_kw in d.get('static', []): 229 | s = Static(**static_kw) 230 | s.file = index.absolute_path(s.file) 231 | yield s 232 | 233 | index._generate_static = _generate_static 234 | 235 | for filter_id, filter_conf in d.get('filters', {}).iteritems(): 236 | filter_cls = import_object(filter_conf['class']) 237 | index.register_filter(filter_id, filter_cls, filter_kwargs=filter_conf.get('kwargs')) 238 | 239 | return index 240 | 241 | def to_dict(self): 242 | # TODO: Make paths relative to base_path 243 | r = { 244 | 'routes': [], 245 | 'static': [], 246 | 'filters': {}, 247 | } 248 | 249 | for route in self.routes: 250 | r['routes'].append({ 251 | 'url': route.url, 252 | 'file': route.file, 253 | 'filters': route.filters, 254 | 'context': route.context, 255 | }) 256 | 257 | for static in self.static: 258 | r['static'].append({ 259 | 'url': static.url, 260 | 'file': static.file, 261 | }) 262 | 263 | for filter_id, filter_obj in self.filters.iteritems(): 264 | r['filters'][filter_id] = { 265 | 'class': '%s:%s' % (filter_obj.__module__, filter_obj.__class__.__name__), 266 | 'kwargs': self._filters_kwargs_cache.get(filter_id, {}), 267 | } 268 | 269 | return r 270 | -------------------------------------------------------------------------------- /composer/server.py: -------------------------------------------------------------------------------- 1 | # composer/server.py 2 | # Copyright 2011 Andrey Petrov 3 | # 4 | # This module is part of Composer and is released under 5 | # the MIT License: http://www.opensource.org/licenses/mit-license.php 6 | import logging 7 | 8 | from .writer import WSGIWriter 9 | 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def serve(index, host='localhost', port=8080, debug=True, **kw): 15 | from werkzeug.wsgi import SharedDataMiddleware 16 | from werkzeug.serving import run_simple 17 | 18 | app = WSGIWriter(index) 19 | 20 | static_routes = dict((index.absolute_url(s.url), index.absolute_path(s.file)) for s in index.static) 21 | 22 | log.debug("Adding static routes: %r", static_routes) 23 | app = SharedDataMiddleware(app, static_routes) 24 | 25 | run_simple(host, port, app, use_debugger=debug, **kw) 26 | -------------------------------------------------------------------------------- /composer/writer.py: -------------------------------------------------------------------------------- 1 | # composer/writer.py 2 | # Copyright 2011 Andrey Petrov 3 | # 4 | # This module is part of Composer and is released under 5 | # the MIT License: http://www.opensource.org/licenses/mit-license.php 6 | 7 | 8 | import codecs 9 | import logging 10 | import mimetypes 11 | import os 12 | 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | class Writer(object): 18 | """ 19 | Writer only cares about the ``filters`` and ``base_path`` in the 20 | ``index``. It doesn't know anything about the routes, but only knows 21 | how to render them once they're received. 22 | """ 23 | def __init__(self, index): 24 | self.index = index 25 | 26 | def _guess_content_type(self, path): 27 | filename = os.path.basename(path) 28 | 29 | if '.' not in filename: # Assume html 30 | return 'text/html' 31 | 32 | return mimetypes.guess_type(path)[0] 33 | 34 | def render_route(self, route): 35 | file_path = route.file 36 | with codecs.open(file_path, encoding='utf8') as fp: 37 | content = fp.read() 38 | 39 | for filter_id in route.filters: 40 | content = self.index.filters[filter_id](content, route=route) 41 | 42 | return content 43 | 44 | def __call__(self, path): 45 | route = self.index.get_route(path) 46 | if route: 47 | return self.render_route(route) 48 | 49 | 50 | class WSGIWriter(Writer): 51 | 52 | def __call__(self, environ, start_response): 53 | # Translate to remove the base_url 54 | path = environ.get('PATH_INFO', '') 55 | content = super(WSGIWriter, self).__call__(path) 56 | 57 | if content is None: 58 | start_response('404 NOT FOUND', [('Content-Type', 'text/plain')]) 59 | return ['Not Found'] 60 | 61 | content_type = self._guess_content_type(path) 62 | if not content_type: 63 | content_type = 'application/octet-stream' 64 | log.warn("Serving literal file of unknown content type: /%s " 65 | "(Hint: Add / suffix to treat it as a directory)", path) 66 | 67 | start_response('200 OK', [('Content-Type', content_type)]) 68 | return [content] 69 | 70 | 71 | class FileWriter(Writer): 72 | """ 73 | Writer who creates a static filesystem structure to mimic the desired url 74 | structures. 75 | """ 76 | def __init__(self, index, build_path='build'): 77 | super(FileWriter, self).__init__(index) 78 | 79 | self.build_path = build_path 80 | 81 | self._prepare_dir(build_path) 82 | 83 | def _get_materialize_path(self, url, default_index_file='index.html'): 84 | url = url.lstrip('/') 85 | 86 | index_file = default_index_file 87 | url_index = os.path.basename(url) 88 | if '.' in url_index: 89 | index_file = url_index 90 | url = os.path.dirname(url) 91 | 92 | if not url: 93 | return self.build_path, index_file 94 | 95 | url_path = os.path.join(self.build_path, url) 96 | return url_path, index_file 97 | 98 | def _prepare_dir(self, path): 99 | if not os.path.exists(path): 100 | os.makedirs(path) 101 | 102 | def _write_file(self, path, content): 103 | fp = open(path, 'w') 104 | fp.write(content) 105 | fp.close() 106 | 107 | def materialize_url(self, url, content=None, default_index_file='index.html'): 108 | url = url.lstrip('/') 109 | 110 | if not self._guess_content_type(url): 111 | log.warn("Materializing literal file of unknown content type: /%s " 112 | "(Hint: Add '/' suffix to treat it as a directory)", url) 113 | 114 | log.info("Materializing: /%s", url) 115 | 116 | url_path, index_file = self._get_materialize_path(url, default_index_file) 117 | 118 | self._prepare_dir(url_path) 119 | 120 | file_path = index_file and os.path.join(url_path, index_file) 121 | 122 | if not file_path or content is None: 123 | return url_path 124 | 125 | self._write_file(file_path, content) 126 | return file_path 127 | 128 | def __call__(self, path): 129 | content = super(FileWriter, self).__call__(path) 130 | 131 | index_file = 'index.html' 132 | if path.endswith('.html'): 133 | # Leave .html files alone. 134 | # TODO: Allow other pass-through extensions like .htm? 135 | path, index_file = os.path.dirname(path), os.path.basename(path) 136 | 137 | self.materialize_url(path, content, index_file) 138 | -------------------------------------------------------------------------------- /examples/simple_mako/foo.mako: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${route.context['title']} 4 | 5 | 6 | 7 | Hello, world. 8 | 9 | First post. 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/simple_mako/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "url": "/foo", 5 | "context": { 6 | "title": "Hello" 7 | }, 8 | "filters": [ 9 | "mako" 10 | ], 11 | "file": "foo.mako" 12 | }, 13 | { 14 | "url": "/post/1", 15 | "context": { 16 | "title": "Hello" 17 | }, 18 | "filters": [ 19 | "markdown", 20 | "mako" 21 | ], 22 | "file": "posts/1.md" 23 | } 24 | ], 25 | "static": [ 26 | { 27 | "url": "/static", 28 | "file": "static" 29 | } 30 | ], 31 | "filters": { 32 | "mako": { 33 | "class": "composer.filters:Mako", 34 | "kwargs": {} 35 | }, 36 | "markdown": { 37 | "class": "composer.filters:Markdown", 38 | "kwargs": {} 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/simple_mako/indexer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from composer.index import Index, Route, Static 4 | from composer.filters import MakoContainer 5 | 6 | 7 | class SimpleIndex(Index): 8 | 9 | def _register_filters(self): 10 | self.register_filter('post', MakoContainer, {'directories': ['.'], 'template': 'post.mako'}) 11 | 12 | def _generate_static(self): 13 | yield Static('/static', 'static') 14 | 15 | def _generate_routes(self): 16 | context = {'title': 'Hello'} 17 | 18 | for path in self.walk(include_only=['*.mako'], exclude=['post.mako']): 19 | url = self.absolute_url(path.split('.')[0]) 20 | yield Route(url, path, filters=['mako'], context=context) 21 | 22 | for path in self.walk('posts', include_only=['*.md']): 23 | url = self.absolute_url('post/%s' % path.split('/', 1)[1].split('.')[0]) 24 | yield Route(url, path, filters=['markdown', 'mako'], context=context) 25 | 26 | if __name__ == '__main__': 27 | import json 28 | import os 29 | 30 | index = SimpleIndex(os.path.dirname(__file__)) 31 | print json.dumps(index.to_dict(), indent=4) 32 | -------------------------------------------------------------------------------- /examples/simple_mako/post.mako: -------------------------------------------------------------------------------- 1 | 2 | ${route.context['title']} 3 | 4 | ${body} 5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/simple_mako/posts/1.md: -------------------------------------------------------------------------------- 1 | # This is a title 2 | 3 | * This 4 | * is 5 | * a 6 | * list 7 | 8 | This is some random stuff. 9 | 10 | -------------------------------------------------------------------------------- /examples/simple_mako/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #fee; 3 | } 4 | -------------------------------------------------------------------------------- /optional.txt: -------------------------------------------------------------------------------- 1 | # Composer includes optional filters which have the following dependencies: 2 | # 3 | # Dependency: Filters: 4 | 5 | Mako # composer.filters.Mako, composer.filters.MakoContainer 6 | Jinja2 # composer.filters.Jinja2 7 | markdown # composer.filters.Markdown 8 | docutils # composer.filters.RestructuredText 9 | pygments # composer.filters.Pygments 10 | 11 | # You'll need to install the dependencies manually if you plan on using these 12 | # filters, else an ImportError exception will be raised during instantiation. 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | logging-clear-handlers=true 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | import os 6 | import re 7 | 8 | 9 | try: 10 | import setuptools 11 | except ImportError, _: 12 | pass # No 'develop' command, oh well. 13 | 14 | base_path = os.path.dirname(__file__) 15 | 16 | # Get the version (borrowed from SQLAlchemy) 17 | fp = open(os.path.join(base_path, 'composer', '__init__.py')) 18 | VERSION = re.compile(r".*__version__ = '(.*?)'", 19 | re.S).match(fp.read()).group(1) 20 | fp.close() 21 | 22 | 23 | version = VERSION 24 | 25 | requirements = [ 26 | 'werkzeug', 27 | ] 28 | 29 | tests_requirements = requirements + [ 30 | 'nose', 31 | ] 32 | 33 | setup(name='Composer', 34 | version=version, 35 | description="Compose dynamic templates and markup into a static website.", 36 | long_description=open('README.rst').read() + '\n\n' + open('CHANGES.rst').read(), 37 | keywords='template compile dynamic static web html mako', 38 | author='Andrey Petrov', 39 | author_email='andrey.petrov@shazow.net', 40 | url='https://github.com/shazow/composer', 41 | license='MIT', 42 | packages=['composer'], 43 | requires=requirements, 44 | tests_require=tests_requirements, 45 | entry_points=""" 46 | [console_scripts] 47 | composer = composer.command:main 48 | """ 49 | ) 50 | 51 | 52 | print """ 53 | --- 54 | 55 | %s 56 | 57 | --- 58 | 59 | You can install all the optional dependencies with: :: 60 | 61 | pip install -r optional.txt 62 | 63 | """ % open('optional.txt').read().strip() 64 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shazow/composer/653b297fc29313ae454f72006bc3712f580f07d2/test/__init__.py -------------------------------------------------------------------------------- /test/test_index.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | 4 | sys.path.append('../') 5 | 6 | 7 | from composer.index import Index, Route 8 | 9 | class TestIndex(unittest.TestCase): 10 | def test_routes(self): 11 | 12 | class TestIndex(Index): 13 | def _generate_routes(self): 14 | yield Route('/foo', 'bar', filters=['baz'], context={'quux': 42}) 15 | yield Route('/a', 'b') 16 | 17 | index = TestIndex() 18 | r = index.to_dict() 19 | 20 | self.assertEqual(r['routes'], [ 21 | {'url': '/foo', 'file': 'bar', 'context': {'quux': 42}, 'filters': ['baz']}, 22 | {'url': '/a', 'file': 'b', 'context': None, 'filters': []}, 23 | ]) 24 | 25 | 26 | 27 | if __name__ == '__main__': 28 | unittest.main() 29 | -------------------------------------------------------------------------------- /test/test_writer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | 4 | sys.path.append('../') 5 | 6 | from composer.index import Index 7 | from composer.writer import FileWriter 8 | 9 | 10 | class DummyFileWriter(FileWriter): 11 | def __init__(self, *args, **kw): 12 | self.reset() 13 | 14 | super(DummyFileWriter, self).__init__(*args, **kw) 15 | 16 | def reset(self): 17 | self._made_dirs = [] 18 | self._written_files = [] 19 | 20 | def _prepare_dir(self, path): 21 | if path not in self._made_dirs: 22 | self._made_dirs.append(path) 23 | 24 | def _write_file(self, path, content): 25 | self._written_files.append(path) 26 | 27 | 28 | class TestWriter(unittest.TestCase): 29 | def test_file_materialize_path(self): 30 | w = DummyFileWriter(Index()) 31 | 32 | self.assertEqual(w._made_dirs, ['build']) 33 | 34 | test_values = [ 35 | ('/', ['build'], ['build/index.html']), 36 | ('/foo', ['build/foo'], ['build/foo/index.html']), 37 | ('/bar.html', ['build'], ['build/bar.html']), 38 | ('/bar.html/', ['build/bar.html/'], ['build/bar.html/index.html']), 39 | ('/bar.someunknownext', ['build'], ['build/bar.someunknownext']), 40 | ] 41 | 42 | for url, made_dirs, written_files in test_values: 43 | w.reset() 44 | w.materialize_url(url, '') 45 | self.assertEqual(w._made_dirs, made_dirs) 46 | self.assertEqual(w._written_files, written_files) 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | --------------------------------------------------------------------------------