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