comparison piecrust/serving/server.py @ 374:fa3ee8a8ee2d

serve: Split the server code in a couple modules inside a `serving` package. This makes the `serve` command's code a bit more removed from implementation details, and paves the way for the CMS mode.
author Ludovic Chabant <ludovic@chabant.com>
date Thu, 07 May 2015 21:37:38 -0700
parents
children aade4ea57e7f
comparison
equal deleted inserted replaced
373:9fb7c4921d75 374:fa3ee8a8ee2d
1 import io
2 import os
3 import re
4 import gzip
5 import time
6 import os.path
7 import hashlib
8 import logging
9 import datetime
10 from werkzeug.exceptions import (
11 NotFound, MethodNotAllowed, InternalServerError, HTTPException)
12 from werkzeug.wrappers import Request, Response
13 from werkzeug.wsgi import ClosingIterator, wrap_file
14 from jinja2 import FileSystemLoader, Environment
15 from piecrust.app import PieCrust
16 from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
17 from piecrust.sources.base import MODE_PARSING
18 from piecrust.uriutil import split_sub_uri
19
20
21 logger = logging.getLogger(__name__)
22
23
24 class ServeRecord(object):
25 def __init__(self):
26 self.entries = {}
27
28 def addEntry(self, entry):
29 key = self._makeKey(entry.uri, entry.sub_num)
30 self.entries[key] = entry
31
32 def getEntry(self, uri, sub_num):
33 key = self._makeKey(uri, sub_num)
34 return self.entries.get(key)
35
36 def _makeKey(self, uri, sub_num):
37 return "%s:%s" % (uri, sub_num)
38
39
40 class ServeRecordPageEntry(object):
41 def __init__(self, uri, sub_num):
42 self.uri = uri
43 self.sub_num = sub_num
44 self.used_source_names = set()
45
46
47 class WsgiServerWrapper(object):
48 def __init__(self, server):
49 self.server = server
50
51 def __call__(self, environ, start_response):
52 return self.server._run_request(environ, start_response)
53
54
55 class Server(object):
56 def __init__(self, root_dir,
57 debug=False, sub_cache_dir=None,
58 static_preview=True, run_sse_check=None):
59 self.root_dir = root_dir
60 self.debug = debug
61 self.sub_cache_dir = sub_cache_dir
62 self.run_sse_check = run_sse_check
63 self.static_preview = static_preview
64 self._out_dir = None
65 self._page_record = None
66 self._proc_loop = None
67 self._mimetype_map = load_mimetype_map()
68
69 def getWsgiApp(self):
70 # Bake all the assets so we know what we have, and so we can serve
71 # them to the client. We need a temp app for this.
72 app = PieCrust(root_dir=self.root_dir, debug=self.debug)
73 app._useSubCacheDir(self.sub_cache_dir)
74 self._out_dir = os.path.join(app.sub_cache_dir, 'server')
75 self._page_record = ServeRecord()
76
77 if not self.run_sse_check or self.run_sse_check():
78 # When using a server with code reloading, some implementations
79 # use process forking and we end up going here twice. We only want
80 # to start the pipeline loop in the inner process most of the
81 # time so we let the implementation tell us if this is OK.
82 from piecrust.processing.base import ProcessorPipeline
83 from piecrust.serving.procloop import ProcessingLoop
84 pipeline = ProcessorPipeline(app, self._out_dir)
85 self._proc_loop = ProcessingLoop(pipeline)
86 self._proc_loop.start()
87
88 # Run the WSGI app.
89 wsgi_wrapper = WsgiServerWrapper(self)
90 return wsgi_wrapper
91
92 def _run_request(self, environ, start_response):
93 try:
94 return self._try_run_request(environ, start_response)
95 except Exception as ex:
96 if self.debug:
97 raise
98 return self._handle_error(ex, environ, start_response)
99
100 def _try_run_request(self, environ, start_response):
101 request = Request(environ)
102
103 # We don't support anything else than GET requests since we're
104 # previewing something that will be static later.
105 if self.static_preview and request.method != 'GET':
106 logger.error("Only GET requests are allowed, got %s" %
107 request.method)
108 raise MethodNotAllowed()
109
110 # Handle special requests right away.
111 response = self._try_special_request(environ, request)
112 if response is not None:
113 return response(environ, start_response)
114
115 # Also handle requests to a pipeline-built asset right away.
116 response = self._try_serve_asset(environ, request)
117 if response is not None:
118 return response(environ, start_response)
119
120 # Create the app for this request.
121 app = PieCrust(root_dir=self.root_dir, debug=self.debug)
122 app._useSubCacheDir(self.sub_cache_dir)
123 app.config.set('site/root', '/')
124 app.config.set('server/is_serving', True)
125 if (app.config.get('site/enable_debug_info') and
126 '!debug' in request.args):
127 app.config.set('site/show_debug_info', True)
128
129 # We'll serve page assets directly from where they are.
130 app.env.base_asset_url_format = '/_asset/%path%'
131
132 # Let's see if it can be a page asset.
133 response = self._try_serve_page_asset(app, environ, request)
134 if response is not None:
135 return response(environ, start_response)
136
137 # Nope. Let's see if it's an actual page.
138 try:
139 response = self._try_serve_page(app, environ, request)
140 return response(environ, start_response)
141 except (RouteNotFoundError, SourceNotFoundError) as ex:
142 raise NotFound(str(ex)) from ex
143 except HTTPException:
144 raise
145 except Exception as ex:
146 if app.debug:
147 logger.exception(ex)
148 raise
149 msg = str(ex)
150 logger.error(msg)
151 raise InternalServerError(msg) from ex
152
153 def _try_special_request(self, environ, request):
154 static_mount = '/__piecrust_static/'
155 if request.path.startswith(static_mount):
156 rel_req_path = request.path[len(static_mount):]
157 mount = os.path.join(
158 os.path.dirname(__file__),
159 'resources', 'server')
160 full_path = os.path.join(mount, rel_req_path)
161 try:
162 response = self._make_wrapped_file_response(
163 environ, request, full_path)
164 return response
165 except OSError:
166 pass
167
168 debug_mount = '/__piecrust_debug/'
169 if request.path.startswith(debug_mount):
170 rel_req_path = request.path[len(debug_mount):]
171 if rel_req_path == 'pipeline_status':
172 from piecrust.server.procloop import (
173 PipelineStatusServerSideEventProducer)
174 provider = PipelineStatusServerSideEventProducer(
175 self._proc_loop.status_queue)
176 it = ClosingIterator(provider.run(), [provider.close])
177 response = Response(it)
178 response.headers['Cache-Control'] = 'no-cache'
179 if 'text/event-stream' in request.accept_mimetypes:
180 response.mimetype = 'text/event-stream'
181 response.direct_passthrough = True
182 response.implicit_sequence_conversion = False
183 return response
184
185 return None
186
187 def _try_serve_asset(self, environ, request):
188 rel_req_path = request.path.lstrip('/').replace('/', os.sep)
189 if request.path.startswith('/_cache/'):
190 # Some stuff needs to be served directly from the cache directory,
191 # like LESS CSS map files.
192 full_path = os.path.join(self.root_dir, rel_req_path)
193 else:
194 full_path = os.path.join(self._out_dir, rel_req_path)
195
196 try:
197 response = self._make_wrapped_file_response(
198 environ, request, full_path)
199 return response
200 except OSError:
201 pass
202 return None
203
204 def _try_serve_page_asset(self, app, environ, request):
205 if not request.path.startswith('/_asset/'):
206 return None
207
208 full_path = os.path.join(app.root_dir, request.path[len('/_asset/'):])
209 if not os.path.isfile(full_path):
210 return None
211
212 return self._make_wrapped_file_response(environ, request, full_path)
213
214 def _try_serve_page(self, app, environ, request):
215 # Try to find what matches the requested URL.
216 req_path, page_num = split_sub_uri(app, request.path)
217
218 routes = find_routes(app.routes, req_path)
219 if len(routes) == 0:
220 raise RouteNotFoundError("Can't find route for: %s" % req_path)
221
222 rendered_page = None
223 first_not_found = None
224 for route, route_metadata in routes:
225 try:
226 logger.debug("Trying to render match from source '%s'." %
227 route.source_name)
228 rendered_page = self._try_render_page(
229 app, route, route_metadata, page_num, req_path)
230 if rendered_page is not None:
231 break
232 except NotFound as nfe:
233 if first_not_found is None:
234 first_not_found = nfe
235 else:
236 raise SourceNotFoundError(
237 "Can't find path for: %s (looked in: %s)" %
238 (req_path, [r.source_name for r, _ in routes]))
239
240 # If we haven't found any good match, raise whatever exception we
241 # first got. Otherwise, raise a generic exception.
242 if rendered_page is None:
243 first_not_found = first_not_found or NotFound(
244 "This page couldn't be found.")
245 raise first_not_found
246
247 # Start doing stuff.
248 page = rendered_page.page
249 rp_content = rendered_page.content
250
251 # Profiling.
252 if app.config.get('site/show_debug_info'):
253 now_time = time.clock()
254 timing_info = (
255 '%8.1f ms' %
256 ((now_time - app.env.start_time) * 1000.0))
257 rp_content = rp_content.replace(
258 '__PIECRUST_TIMING_INFORMATION__', timing_info)
259
260 # Build the response.
261 response = Response()
262
263 etag = hashlib.md5(rp_content.encode('utf8')).hexdigest()
264 if not app.debug and etag in request.if_none_match:
265 response.status_code = 304
266 return response
267
268 response.set_etag(etag)
269 response.content_md5 = etag
270
271 cache_control = response.cache_control
272 if app.debug:
273 cache_control.no_cache = True
274 cache_control.must_revalidate = True
275 else:
276 cache_time = (page.config.get('cache_time') or
277 app.config.get('site/cache_time'))
278 if cache_time:
279 cache_control.public = True
280 cache_control.max_age = cache_time
281
282 content_type = page.config.get('content_type')
283 if content_type and '/' not in content_type:
284 mimetype = content_type_map.get(content_type, content_type)
285 else:
286 mimetype = content_type
287 if mimetype:
288 response.mimetype = mimetype
289
290 if ('gzip' in request.accept_encodings and
291 app.config.get('site/enable_gzip')):
292 try:
293 with io.BytesIO() as gzip_buffer:
294 with gzip.open(gzip_buffer, mode='wt',
295 encoding='utf8') as gzip_file:
296 gzip_file.write(rp_content)
297 rp_content = gzip_buffer.getvalue()
298 response.content_encoding = 'gzip'
299 except Exception:
300 logger.exception("Error compressing response, "
301 "falling back to uncompressed.")
302 response.set_data(rp_content)
303
304 return response
305
306 def _try_render_page(self, app, route, route_metadata, page_num, req_path):
307 # Match the route to an actual factory.
308 taxonomy_info = None
309 source = app.getSource(route.source_name)
310 if route.taxonomy_name is None:
311 factory = source.findPageFactory(route_metadata, MODE_PARSING)
312 if factory is None:
313 return None
314 else:
315 taxonomy = app.getTaxonomy(route.taxonomy_name)
316 route_terms = route_metadata.get(taxonomy.term_name)
317 if route_terms is None:
318 return None
319
320 tax_page_ref = taxonomy.getPageRef(source.name)
321 factory = tax_page_ref.getFactory()
322 tax_terms = route.unslugifyTaxonomyTerm(route_terms)
323 route_metadata[taxonomy.term_name] = tax_terms
324 taxonomy_info = (taxonomy, tax_terms)
325
326 # Build the page.
327 page = factory.buildPage()
328 # We force the rendering of the page because it could not have
329 # changed, but include pages that did change.
330 qp = QualifiedPage(page, route, route_metadata)
331 render_ctx = PageRenderingContext(qp,
332 page_num=page_num,
333 force_render=True)
334 if taxonomy_info is not None:
335 taxonomy, tax_terms = taxonomy_info
336 render_ctx.setTaxonomyFilter(taxonomy, tax_terms)
337
338 # See if this page is known to use sources. If that's the case,
339 # just don't use cached rendered segments for that page (but still
340 # use them for pages that are included in it).
341 uri = qp.getUri()
342 assert uri == req_path
343 entry = self._page_record.getEntry(uri, page_num)
344 if (taxonomy_info is not None or entry is None or
345 entry.used_source_names):
346 cache_key = '%s:%s' % (uri, page_num)
347 app.env.rendered_segments_repository.invalidate(cache_key)
348
349 # Render the page.
350 rendered_page = render_page(render_ctx)
351
352 # Check if this page is a taxonomy page that actually doesn't match
353 # anything.
354 if taxonomy_info is not None:
355 paginator = rendered_page.data.get('pagination')
356 if (paginator and paginator.is_loaded and
357 len(paginator.items) == 0):
358 taxonomy = taxonomy_info[0]
359 message = ("This URL matched a route for taxonomy '%s' but "
360 "no pages have been found to have it. This page "
361 "won't be generated by a bake." % taxonomy.name)
362 raise NotFound(message)
363
364 # Remember stuff for next time.
365 if entry is None:
366 entry = ServeRecordPageEntry(req_path, page_num)
367 self._page_record.addEntry(entry)
368 for p, pinfo in render_ctx.render_passes.items():
369 entry.used_source_names |= pinfo.used_source_names
370
371 # Ok all good.
372 return rendered_page
373
374 def _make_wrapped_file_response(self, environ, request, path):
375 logger.debug("Serving %s" % path)
376
377 # Check if we can return a 304 status code.
378 mtime = os.path.getmtime(path)
379 etag_str = '%s$$%s' % (path, mtime)
380 etag = hashlib.md5(etag_str.encode('utf8')).hexdigest()
381 if etag in request.if_none_match:
382 response = Response()
383 response.status_code = 304
384 return response
385
386 wrapper = wrap_file(environ, open(path, 'rb'))
387 response = Response(wrapper)
388 _, ext = os.path.splitext(path)
389 response.set_etag(etag)
390 response.last_modified = datetime.datetime.fromtimestamp(mtime)
391 response.mimetype = self._mimetype_map.get(
392 ext.lstrip('.'), 'text/plain')
393 return response
394
395 def _handle_error(self, exception, environ, start_response):
396 code = 500
397 if isinstance(exception, HTTPException):
398 code = exception.code
399
400 path = 'error'
401 if isinstance(exception, NotFound):
402 path += '404'
403
404 descriptions = self._get_exception_descriptions(exception)
405
406 env = Environment(loader=ErrorMessageLoader())
407 template = env.get_template(path)
408 context = {'details': descriptions}
409 response = Response(template.render(context), mimetype='text/html')
410 response.status_code = code
411 return response(environ, start_response)
412
413 def _get_exception_descriptions(self, exception):
414 desc = []
415 while exception is not None:
416 if isinstance(exception, HTTPException):
417 desc.append(exception.description)
418 else:
419 desc.append(str(exception))
420
421 inner_ex = exception.__cause__
422 if inner_ex is None:
423 inner_ex = exception.__context__
424 exception = inner_ex
425 return desc
426
427
428 class RouteNotFoundError(Exception):
429 pass
430
431
432 class SourceNotFoundError(Exception):
433 pass
434
435
436 content_type_map = {
437 'html': 'text/html',
438 'xml': 'text/xml',
439 'txt': 'text/plain',
440 'text': 'text/plain',
441 'css': 'text/css',
442 'xhtml': 'application/xhtml+xml',
443 'atom': 'application/atom+xml', # or 'text/xml'?
444 'rss': 'application/rss+xml', # or 'text/xml'?
445 'json': 'application/json'}
446
447
448 def find_routes(routes, uri):
449 res = []
450 for route in routes:
451 metadata = route.matchUri(uri)
452 if metadata is not None:
453 res.append((route, metadata))
454 return res
455
456
457 class ErrorMessageLoader(FileSystemLoader):
458 def __init__(self):
459 base_dir = os.path.join(os.path.dirname(__file__), 'resources',
460 'messages')
461 super(ErrorMessageLoader, self).__init__(base_dir)
462
463 def get_source(self, env, template):
464 template += '.html'
465 return super(ErrorMessageLoader, self).get_source(env, template)
466
467
468 def load_mimetype_map():
469 mimetype_map = {}
470 sep_re = re.compile(r'\s+')
471 path = os.path.join(os.path.dirname(__file__), 'mime.types')
472 with open(path, 'r') as f:
473 for line in f:
474 tokens = sep_re.split(line)
475 if len(tokens) > 1:
476 for t in tokens[1:]:
477 mimetype_map[t] = tokens[0]
478 return mimetype_map
479