Mercurial > piecrust2
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 |