comparison piecrust/serving/server.py @ 553:cc6f3dbe3048

serve: Extract some of the server's functionality into WSGI middlewares.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 08 Aug 2015 22:01:47 -0700
parents 9612cfc6455a
children 155c7e20414f
comparison
equal deleted inserted replaced
552:9612cfc6455a 553:cc6f3dbe3048
1 import io 1 import io
2 import os 2 import os
3 import re
4 import gzip 3 import gzip
5 import time 4 import time
6 import os.path 5 import os.path
7 import hashlib 6 import hashlib
8 import logging 7 import logging
9 import datetime
10 from werkzeug.exceptions import ( 8 from werkzeug.exceptions import (
11 NotFound, MethodNotAllowed, InternalServerError, HTTPException) 9 NotFound, MethodNotAllowed, InternalServerError, HTTPException)
12 from werkzeug.wrappers import Request, Response 10 from werkzeug.wrappers import Request, Response
13 from werkzeug.wsgi import ClosingIterator, wrap_file
14 from jinja2 import FileSystemLoader, Environment 11 from jinja2 import FileSystemLoader, Environment
15 from piecrust import CACHE_DIR, RESOURCES_DIR 12 from piecrust import CACHE_DIR, RESOURCES_DIR
16 from piecrust.app import PieCrust 13 from piecrust.app import PieCrust
17 from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page 14 from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
15 from piecrust.serving.util import content_type_map, make_wrapped_file_response
18 from piecrust.sources.base import MODE_PARSING 16 from piecrust.sources.base import MODE_PARSING
19 from piecrust.uriutil import split_sub_uri 17 from piecrust.uriutil import split_sub_uri
20 18
21 19
22 logger = logging.getLogger(__name__) 20 logger = logging.getLogger(__name__)
21
22
23 class WsgiServer(object):
24 def __init__(self, root_dir, **kwargs):
25 self.server = Server(root_dir, **kwargs)
26
27 def __call__(self, environ, start_response):
28 return self.server._run_request(environ, start_response)
23 29
24 30
25 class ServeRecord(object): 31 class ServeRecord(object):
26 def __init__(self): 32 def __init__(self):
27 self.entries = {} 33 self.entries = {}
41 class ServeRecordPageEntry(object): 47 class ServeRecordPageEntry(object):
42 def __init__(self, uri, sub_num): 48 def __init__(self, uri, sub_num):
43 self.uri = uri 49 self.uri = uri
44 self.sub_num = sub_num 50 self.sub_num = sub_num
45 self.used_source_names = set() 51 self.used_source_names = set()
46
47
48 class WsgiServerWrapper(object):
49 def __init__(self, server):
50 self.server = server
51
52 def __call__(self, environ, start_response):
53 return self.server._run_request(environ, start_response)
54 52
55 53
56 class MultipleNotFound(HTTPException): 54 class MultipleNotFound(HTTPException):
57 code = 404 55 code = 404
58 56
71 69
72 70
73 class Server(object): 71 class Server(object):
74 def __init__(self, root_dir, 72 def __init__(self, root_dir,
75 debug=False, sub_cache_dir=None, enable_debug_info=True, 73 debug=False, sub_cache_dir=None, enable_debug_info=True,
76 static_preview=True, run_sse_check=None): 74 static_preview=True):
77 self.root_dir = root_dir 75 self.root_dir = root_dir
78 self.debug = debug 76 self.debug = debug
79 self.sub_cache_dir = sub_cache_dir 77 self.sub_cache_dir = sub_cache_dir
80 self.enable_debug_info = enable_debug_info 78 self.enable_debug_info = enable_debug_info
81 self.run_sse_check = run_sse_check
82 self.static_preview = static_preview 79 self.static_preview = static_preview
83 self._page_record = ServeRecord() 80 self._page_record = ServeRecord()
84 self._out_dir = os.path.join(root_dir, CACHE_DIR, 'server') 81 self._out_dir = os.path.join(root_dir, CACHE_DIR, 'server')
85 self._proc_loop = None 82 if sub_cache_dir:
86 self._mimetype_map = load_mimetype_map() 83 self._out_dir = os.path.join(sub_cache_dir, 'server')
87
88 def getWsgiApp(self):
89 # Bake all the assets so we know what we have, and so we can serve
90 # them to the client. We need a temp app for this.
91 app = PieCrust(root_dir=self.root_dir, debug=self.debug)
92 if self.sub_cache_dir:
93 app._useSubCacheDir(self.sub_cache_dir)
94 self._out_dir = os.path.join(app.sub_cache_dir, 'server')
95
96 if not self.run_sse_check or self.run_sse_check():
97 # When using a server with code reloading, some implementations
98 # use process forking and we end up going here twice. We only want
99 # to start the pipeline loop in the inner process most of the
100 # time so we let the implementation tell us if this is OK.
101 from piecrust.processing.pipeline import ProcessorPipeline
102 from piecrust.serving.procloop import ProcessingLoop
103 pipeline = ProcessorPipeline(app, self._out_dir)
104 self._proc_loop = ProcessingLoop(pipeline)
105 self._proc_loop.start()
106
107 # Run the WSGI app.
108 wsgi_wrapper = WsgiServerWrapper(self)
109 return wsgi_wrapper
110 84
111 def _run_request(self, environ, start_response): 85 def _run_request(self, environ, start_response):
112 try: 86 try:
113 response = self._try_run_request(environ) 87 response = self._try_run_request(environ)
114 if isinstance(response, tuple): 88 return response(environ, start_response)
115 response, close_func = response
116 return ClosingIterator(response(environ, start_response),
117 [close_func])
118 else:
119 return response(environ, start_response)
120 except Exception as ex: 89 except Exception as ex:
121 if self.debug: 90 if self.debug:
122 raise 91 raise
123 return self._handle_error(ex, environ, start_response) 92 return self._handle_error(ex, environ, start_response)
124 93
129 # previewing something that will be static later. 98 # previewing something that will be static later.
130 if self.static_preview and request.method != 'GET': 99 if self.static_preview and request.method != 'GET':
131 logger.error("Only GET requests are allowed, got %s" % 100 logger.error("Only GET requests are allowed, got %s" %
132 request.method) 101 request.method)
133 raise MethodNotAllowed() 102 raise MethodNotAllowed()
134
135 # Handle special requests right away.
136 response = self._try_special_request(environ, request)
137 if response is not None:
138 return response
139 103
140 # Also handle requests to a pipeline-built asset right away. 104 # Also handle requests to a pipeline-built asset right away.
141 response = self._try_serve_asset(environ, request) 105 response = self._try_serve_asset(environ, request)
142 if response is not None: 106 if response is not None:
143 return response 107 return response
175 raise 139 raise
176 logger.error(str(ex)) 140 logger.error(str(ex))
177 msg = "There was an error trying to serve: %s" % request.path 141 msg = "There was an error trying to serve: %s" % request.path
178 raise InternalServerError(msg) from ex 142 raise InternalServerError(msg) from ex
179 143
180 def _try_special_request(self, environ, request):
181 static_mount = '/__piecrust_static/'
182 if request.path.startswith(static_mount):
183 rel_req_path = request.path[len(static_mount):]
184 mount = os.path.join(RESOURCES_DIR, 'server')
185 full_path = os.path.join(mount, rel_req_path)
186 try:
187 response = self._make_wrapped_file_response(
188 environ, request, full_path)
189 return response
190 except OSError:
191 pass
192
193 debug_mount = '/__piecrust_debug/'
194 if request.path.startswith(debug_mount):
195 rel_req_path = request.path[len(debug_mount):]
196 if rel_req_path == 'werkzeug_shutdown':
197 shutdown_func = environ.get('werkzeug.server.shutdown')
198 if shutdown_func is None:
199 raise RuntimeError('Not running with the Werkzeug Server')
200 shutdown_func()
201 return Response("Server shutting down...")
202
203 if rel_req_path == 'pipeline_status':
204 from piecrust.serving.procloop import (
205 PipelineStatusServerSentEventProducer)
206 provider = PipelineStatusServerSentEventProducer(
207 self._proc_loop)
208 it = provider.run()
209 response = Response(it, mimetype='text/event-stream')
210 response.headers['Cache-Control'] = 'no-cache'
211 response.headers['Last-Event-ID'] = \
212 self._proc_loop.last_status_id
213 return response, provider.close
214
215 return None
216
217 def _try_serve_asset(self, environ, request): 144 def _try_serve_asset(self, environ, request):
218 rel_req_path = request.path.lstrip('/').replace('/', os.sep) 145 rel_req_path = request.path.lstrip('/').replace('/', os.sep)
219 if request.path.startswith('/_cache/'): 146 if request.path.startswith('/_cache/'):
220 # Some stuff needs to be served directly from the cache directory, 147 # Some stuff needs to be served directly from the cache directory,
221 # like LESS CSS map files. 148 # like LESS CSS map files.
222 full_path = os.path.join(self.root_dir, rel_req_path) 149 full_path = os.path.join(self.root_dir, rel_req_path)
223 else: 150 else:
224 full_path = os.path.join(self._out_dir, rel_req_path) 151 full_path = os.path.join(self._out_dir, rel_req_path)
225 152
226 try: 153 try:
227 response = self._make_wrapped_file_response( 154 response = make_wrapped_file_response(environ, request, full_path)
228 environ, request, full_path)
229 return response 155 return response
230 except OSError: 156 except OSError:
231 pass 157 pass
232 return None 158 return None
233 159
237 163
238 full_path = os.path.join(app.root_dir, request.path[len('/_asset/'):]) 164 full_path = os.path.join(app.root_dir, request.path[len('/_asset/'):])
239 if not os.path.isfile(full_path): 165 if not os.path.isfile(full_path):
240 return None 166 return None
241 167
242 return self._make_wrapped_file_response(environ, request, full_path) 168 return make_wrapped_file_response(environ, request, full_path)
243 169
244 def _try_serve_page(self, app, environ, request): 170 def _try_serve_page(self, app, environ, request):
245 # Try to find what matches the requested URL. 171 # Try to find what matches the requested URL.
246 req_path, page_num = split_sub_uri(app, request.path) 172 req_path, page_num = split_sub_uri(app, request.path)
247 173
391 entry.used_source_names |= pinfo.used_source_names 317 entry.used_source_names |= pinfo.used_source_names
392 318
393 # Ok all good. 319 # Ok all good.
394 return rendered_page 320 return rendered_page
395 321
396 def _make_wrapped_file_response(self, environ, request, path):
397 logger.debug("Serving %s" % path)
398
399 # Check if we can return a 304 status code.
400 mtime = os.path.getmtime(path)
401 etag_str = '%s$$%s' % (path, mtime)
402 etag = hashlib.md5(etag_str.encode('utf8')).hexdigest()
403 if etag in request.if_none_match:
404 response = Response()
405 response.status_code = 304
406 return response
407
408 wrapper = wrap_file(environ, open(path, 'rb'))
409 response = Response(wrapper)
410 _, ext = os.path.splitext(path)
411 response.set_etag(etag)
412 response.last_modified = datetime.datetime.fromtimestamp(mtime)
413 response.mimetype = self._mimetype_map.get(
414 ext.lstrip('.'), 'text/plain')
415 response.direct_passthrough = True
416 return response
417
418 def _handle_error(self, exception, environ, start_response): 322 def _handle_error(self, exception, environ, start_response):
419 code = 500 323 code = 500
420 if isinstance(exception, HTTPException): 324 if isinstance(exception, HTTPException):
421 code = exception.code 325 code = exception.code
422 326
454 pass 358 pass
455 359
456 360
457 class SourceNotFoundError(Exception): 361 class SourceNotFoundError(Exception):
458 pass 362 pass
459
460
461 content_type_map = {
462 'html': 'text/html',
463 'xml': 'text/xml',
464 'txt': 'text/plain',
465 'text': 'text/plain',
466 'css': 'text/css',
467 'xhtml': 'application/xhtml+xml',
468 'atom': 'application/atom+xml', # or 'text/xml'?
469 'rss': 'application/rss+xml', # or 'text/xml'?
470 'json': 'application/json'}
471 363
472 364
473 def find_routes(routes, uri): 365 def find_routes(routes, uri):
474 res = [] 366 res = []
475 tax_res = [] 367 tax_res = []
491 def get_source(self, env, template): 383 def get_source(self, env, template):
492 template += '.html' 384 template += '.html'
493 return super(ErrorMessageLoader, self).get_source(env, template) 385 return super(ErrorMessageLoader, self).get_source(env, template)
494 386
495 387
496 def load_mimetype_map():
497 mimetype_map = {}
498 sep_re = re.compile(r'\s+')
499 path = os.path.join(os.path.dirname(__file__), 'mime.types')
500 with open(path, 'r') as f:
501 for line in f:
502 tokens = sep_re.split(line)
503 if len(tokens) > 1:
504 for t in tokens[1:]:
505 mimetype_map[t] = tokens[0]
506 return mimetype_map
507