comparison piecrust/serving.py @ 3:f485ba500df3

Gigantic change to basically make PieCrust 2 vaguely functional. - Serving works, with debug window. - Baking works, multi-threading, with dependency handling. - Various things not implemented yet.
author Ludovic Chabant <ludovic@chabant.com>
date Sun, 10 Aug 2014 23:43:16 -0700
parents
children 474c9882decf
comparison
equal deleted inserted replaced
2:40fa08b261b9 3:f485ba500df3
1 import re
2 import gzip
3 import time
4 import os.path
5 import hashlib
6 import logging
7 import StringIO
8 from werkzeug.exceptions import (NotFound, MethodNotAllowed,
9 InternalServerError)
10 from werkzeug.serving import run_simple
11 from werkzeug.wrappers import Request, Response
12 from werkzeug.wsgi import wrap_file
13 from jinja2 import FileSystemLoader, Environment
14 from piecrust.app import PieCrust
15 from piecrust.data.filters import (PaginationFilter, HasFilterClause,
16 IsFilterClause)
17 from piecrust.page import Page
18 from piecrust.processing.base import ProcessorPipeline
19 from piecrust.rendering import PageRenderingContext, render_page
20 from piecrust.sources.base import MODE_PARSING
21
22
23 logger = logging.getLogger(__name__)
24
25
26
27
28 class Server(object):
29 def __init__(self, root_dir, host='localhost', port='8080',
30 debug=False, static_preview=True):
31 self.root_dir = root_dir
32 self.host = host
33 self.port = port
34 self.debug = debug
35 self.static_preview = static_preview
36 self._out_dir = None
37 self._skip_patterns = None
38 self._force_patterns = None
39 self._record = None
40 self._mimetype_map = load_mimetype_map()
41
42 def run(self):
43 # Bake all the assets so we know what we have, and so we can serve
44 # them to the client. We need a temp app for this.
45 app = PieCrust(root_dir=self.root_dir, debug=self.debug)
46 self._out_dir = os.path.join(app.cache_dir, 'server')
47 self._skip_patterns = app.config.get('baker/skip_patterns')
48 self._force_patterns = app.config.get('baker/force_patterns')
49 pipeline = ProcessorPipeline(
50 app, self._out_dir,
51 skip_patterns=self._skip_patterns,
52 force_patterns=self._force_patterns)
53 self._record = pipeline.run()
54
55 # Run the WSGI app.
56 wsgi_wrapper = WsgiServer(self)
57 run_simple(self.host, self.port, wsgi_wrapper,
58 use_debugger=True, use_reloader=True)
59
60 def _run_request(self, environ, start_response):
61 try:
62 return self._run_piecrust(environ, start_response)
63 except Exception as ex:
64 if self.debug:
65 raise
66 return self._handle_error(ex, environ, start_response)
67
68 def _run_piecrust(self, environ, start_response):
69 request = Request(environ)
70
71 # We don't support anything else than GET requests since we're
72 # previewing something that will be static later.
73 if self.static_preview and request.method != 'GET':
74 logger.error("Only GET requests are allowed, got %s" % request.method)
75 raise MethodNotAllowed()
76
77 # Create the app for this request.
78 app = PieCrust(root_dir=self.root_dir, debug=self.debug)
79
80 # We'll serve page assets directly from where they are.
81 app.env.base_asset_url_format = '/_asset/%path%'
82
83 # See if the requested URL is an asset.
84 response = self._try_serve_asset(app, environ, request)
85 if response is not None:
86 return response(environ, start_response)
87
88 # It's not an asset we know of... let's see if it can be a page asset.
89 response = self._try_serve_page_asset(app, environ, request)
90 if response is not None:
91 return response(environ, start_response)
92
93 # Nope. Let's hope it's an actual page.
94 try:
95 response = self._try_serve_page(app, environ, request)
96 return response(environ, start_response)
97 except (RouteNotFoundError, SourceNotFoundError) as ex:
98 logger.exception(ex)
99 raise NotFound()
100 except Exception as ex:
101 logger.exception(ex)
102 if app.debug:
103 raise
104 raise InternalServerError()
105
106 def _try_serve_asset(self, app, environ, request):
107 logger.debug("Searching for asset with path: %s" % request.path)
108 rel_req_path = request.path.lstrip('/')
109 entry = self._record.findEntry(rel_req_path)
110 if entry is None:
111 return None
112
113 # Yep, we know about this URL because we processed an asset that
114 # maps to it... make sure it's up to date by re-processing it
115 # before serving.
116 asset_in_path = os.path.join(app.root_dir, entry.rel_input)
117 asset_out_path = os.path.join(self._out_dir, rel_req_path)
118 pipeline = ProcessorPipeline(
119 app, self._out_dir,
120 skip_patterns=self._skip_patterns,
121 force_patterns=self._force_patterns)
122 pipeline.run(asset_in_path)
123
124 logger.debug("Serving %s" % asset_out_path)
125 wrapper = wrap_file(environ, open(asset_out_path))
126 response = Response(wrapper)
127 _, ext = os.path.splitext(rel_req_path)
128 response.mimetype = self._mimetype_map.get(
129 ext.lstrip('.'), 'text/plain')
130 return response
131
132 def _try_serve_page_asset(self, app, environ, request):
133 if not request.path.startswith('/_asset/'):
134 return None
135
136 full_path = os.path.join(app.root_dir, request.path[len('/_asset/'):])
137 if not os.path.isfile(full_path):
138 return None
139
140 logger.debug("Serving %s" % full_path)
141 wrapper = wrap_file(environ, open(full_path))
142 response = Response(wrapper)
143 _, ext = os.path.splitext(full_path)
144 response.mimetype = self._mimetype_map.get(
145 ext.lstrip('.'), 'text/plain')
146 return response
147
148 def _try_serve_page(self, app, environ, request):
149 # Try to find what matches the requested URL.
150 req_path = request.path
151 page_num = 1
152 pgn_suffix_re = app.config.get('__cache/pagination_suffix_re')
153 pgn_suffix_m = re.search(pgn_suffix_re, request.path)
154 if pgn_suffix_m:
155 req_path = request.path[:pgn_suffix_m.start()]
156 page_num = int(pgn_suffix_m.group('num'))
157
158 routes = find_routes(app.routes, req_path)
159 if len(routes) == 0:
160 raise RouteNotFoundError("Can't find route for: %s" % req_path)
161
162 taxonomy = None
163 for route, route_metadata in routes:
164 source = app.getSource(route.source_name)
165 if route.taxonomy is None:
166 path, fac_metadata = source.findPagePath(
167 route_metadata, MODE_PARSING)
168 if path is not None:
169 break
170 else:
171 taxonomy = app.getTaxonomy(route.taxonomy)
172 term_value = route_metadata.get(taxonomy.term_name)
173 if term_value is not None:
174 tax_page_ref = taxonomy.getPageRef(source.name)
175 path = tax_page_ref.path
176 source = tax_page_ref.source
177 fac_metadata = {taxonomy.term_name: term_value}
178 break
179 else:
180 raise SourceNotFoundError("Can't find path for: %s "
181 "(looked in: %s)" %
182 (req_path, [r.source_name for r, _ in routes]))
183
184 # Build the page and render it.
185 page = Page(source, fac_metadata, path)
186 render_ctx = PageRenderingContext(page, req_path, page_num)
187 if taxonomy is not None:
188 flt = PaginationFilter()
189 if taxonomy.is_multiple:
190 flt.addClause(HasFilterClause(taxonomy.name, term_value))
191 else:
192 flt.addClause(IsFilterClause(taxonomy.name, term_value))
193 render_ctx.pagination_filter = flt
194
195 render_ctx.custom_data = {
196 taxonomy.term_name: term_value}
197 rendered_page = render_page(render_ctx)
198 rp_content = rendered_page.content
199
200 # Start response.
201 response = Response()
202
203 etag = hashlib.md5(rp_content).hexdigest()
204 if not app.debug and etag in request.if_none_match:
205 response.status_code = 304
206 return response
207
208 response.set_etag(etag)
209 response.content_md5 = etag
210
211 cache_control = response.cache_control
212 if app.debug:
213 cache_control.no_cache = True
214 cache_control.must_revalidate = True
215 else:
216 cache_time = (page.config.get('cache_time') or
217 app.config.get('site/cache_time'))
218 if cache_time:
219 cache_control.public = True
220 cache_control.max_age = cache_time
221
222 content_type = page.config.get('content_type')
223 if content_type and '/' not in content_type:
224 mimetype = content_type_map.get(content_type, content_type)
225 else:
226 mimetype = content_type
227 if mimetype:
228 response.mimetype = mimetype
229
230 if app.debug:
231 now_time = time.clock()
232 timing_info = ('%8.1f ms' %
233 ((now_time - app.env.start_time) * 1000.0))
234 rp_content = rp_content.replace('__PIECRUST_TIMING_INFORMATION__',
235 timing_info)
236
237 if ('gzip' in request.accept_encodings and
238 app.config.get('site/enable_gzip')):
239 try:
240 gzip_buffer = StringIO.StringIO()
241 gzip_file = gzip.GzipFile(
242 mode='wb',
243 compresslevel=9,
244 fileobj=gzip_buffer)
245 gzip_file.write(rp_content)
246 gzip_file.close()
247 rp_content = gzip_buffer.getvalue()
248 response.content_encoding = 'gzip'
249 except Exception:
250 logger.exception("Error compressing response, "
251 "falling back to uncompressed.")
252 rp_content = rendered_page.content
253 response.set_data(rp_content)
254
255 return response
256
257 def _handle_error(self, exception, environ, start_response):
258 path = 'error'
259 if isinstance(exception, NotFound):
260 path = '404'
261 env = Environment(loader=ErrorMessageLoader())
262 template = env.get_template(path)
263 context = {'details': str(exception)}
264 response = Response(template.render(context), mimetype='text/html')
265 return response(environ, start_response)
266
267
268 class WsgiServer(object):
269 def __init__(self, server):
270 self.server = server
271
272 def __call__(self, environ, start_response):
273 return self.server._run_request(environ, start_response)
274
275
276 class RouteNotFoundError(Exception):
277 pass
278
279
280 class SourceNotFoundError(Exception):
281 pass
282
283
284 content_type_map = {
285 'html': 'text/html',
286 'xml': 'text/xml',
287 'txt': 'text/plain',
288 'text': 'text/plain',
289 'css': 'text/css',
290 'xhtml': 'application/xhtml+xml',
291 'atom': 'application/atom+xml', # or 'text/xml'?
292 'rss': 'application/rss+xml', # or 'text/xml'?
293 'json': 'application/json'}
294
295
296 def find_routes(routes, uri):
297 uri = uri.lstrip('/')
298 res = []
299 for route in routes:
300 m = route.uri_re.match(uri)
301 if m:
302 metadata = m.groupdict()
303 res.append((route, metadata))
304 return res
305
306
307 class ErrorMessageLoader(FileSystemLoader):
308 def __init__(self):
309 base_dir = os.path.join(os.path.dirname(__file__), 'resources',
310 'messages')
311 super(ErrorMessageLoader, self).__init__(base_dir)
312
313 def get_source(self, env, template):
314 template += '.html'
315 return super(ErrorMessageLoader, self).get_source(env, template)
316
317
318 def load_mimetype_map():
319 mimetype_map = {}
320 sep_re = re.compile(r'\s+')
321 path = os.path.join(os.path.dirname(__file__), 'mime.types')
322 with open(path, 'r') as f:
323 for line in f:
324 tokens = sep_re.split(line)
325 if len(tokens) > 1:
326 for t in tokens[1:]:
327 mimetype_map[t] = tokens[0]
328 return mimetype_map
329