The TinCan Web Framework
Revisión | e726fafcffaca109693de3f0af2a6c5d9e8917d8 (tree) |
---|---|
Tiempo | 2019-05-13 07:26:28 |
Autor | David Barts <n5jrn@me.c...> |
Commiter | David Barts |
For backup purposes, UNFINISHED!!
@@ -0,0 +1,93 @@ | ||
1 | +<!DOCTYPE html> | |
2 | +<html> | |
3 | + <head> | |
4 | + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> | |
5 | + <title>Introducing TinCan</title> | |
6 | + <style type="text/css"> | |
7 | + .kbd { font-family: monospace; } | |
8 | + </style> | |
9 | + </head> | |
10 | + <body> | |
11 | + <h1>Introducing TinCan, a “Code-Behind” MVC Framework for Bottle</h1> | |
12 | + <h2>Introduction</h2> | |
13 | + <p>TinCan is a Python 3 code-behind web framework implemented in the Bottle | |
14 | + microframework. As with Bottle, all the code is in one module, and there | |
15 | + is no direct dependency on anything that is not part of the standard | |
16 | + Python library (except of course for Bottle itself).</p> | |
17 | + <p>The default templating engine for TinCan is <a href="https://chameleon.readthedocs.io/en/latest/">Chameleon</a>. | |
18 | + TinCan adds Chameleon as a fully-supported templating engine for Bottle. | |
19 | + Any template engine supported by Bottle can be used to render TinCan | |
20 | + Pages.</p> | |
21 | + <h2>Why Do This?</h2> | |
22 | + <p>In short, there is too much repeating oneself in most all Python web | |
23 | + frameworks (and this includes Bottle). One is always saying “this is | |
24 | + controller <span class="kbd">foo</span>, whose view is in the template <span | |
25 | + class="kbd">foo.pt</span>, at route <span class="kbd">/foo</span>.”</p> | |
26 | + <p>That’s a lot more busywork than just writing <span class="kbd">foo.php</span> | |
27 | + or <span class="kbd">foo.cgi</span>, so many simple webapps end up being | |
28 | + implemented via the latter means. That’s unfortunate, as CGI isn’t very | |
29 | + resource-efficient, and there’s much nicer languages to code in than PHP | |
30 | + (such as Python :-) ). Worst of all, you now have logic and presentation | |
31 | + all scrambled together, never a good idea.</p> | |
32 | + <p>What if, instead, you could write <span class="kbd">foo.pspx</span> and | |
33 | + <span class="kbd">foo.py</span>, and a framework would automatically | |
34 | + create the <span class="kbd">/foo.pspx</span> route for you, much like | |
35 | + ASP.NET or JSP would for a <span class="kbd">.aspx</span> or <span class="kbd">.jsp</span> | |
36 | + file? The matching code-behind in the <span class="kbd">.py</span> file | |
37 | + would be easily detected (same name, different extension) and | |
38 | + automatically associated with the template, of course. You could focus on | |
39 | + writing pages instead of repeating yourself saying the obvious over and | |
40 | + over again. </p> | |
41 | + <p>This is what TinCan lets you do.</p> | |
42 | + <h2>Hang On, Code-Behind Isn’t MVC!</h2> | |
43 | + <p>Why <em>isn’t</em> it? The model, as always, is the data and containing | |
44 | + core business logic. The template file defines the view presented to the | |
45 | + user, and the code-behind is the intermediary between the two. A | |
46 | + controller by any other name…</p> | |
47 | + <h2>How Can There Be Multiple Views for One Controller?</h2> | |
48 | + <p>Easily. Take a look at the <code>#python</code> header directive. </p> | |
49 | + <h2>Multiple Controllers for One View?</h2> | |
50 | + <p>Personally, I don’t think this is the best of ideas. Just because two | |
51 | + controllers might be able to share a view <em>now</em> does not mean they | |
52 | + will continue to in the future. Then you change one (controller, view) | |
53 | + pair and another controller someplace else breaks!</p> | |
54 | + <p>However, if you insist, check out the <code>#template</code> and <code>#hidden</code> | |
55 | + header directives. </p> | |
56 | + <h2>But This Causes Less SEO-Friendly Routes!</h2> | |
57 | + <p>First, this is not always important. Sometimes, all you want to do is get | |
58 | + a small, simple, special-purpose, site up and running with a minimum of | |
59 | + busywork. Why should you be forced to do more work just because that extra | |
60 | + work benefits someone <em>else</em>?</p> | |
61 | + <p>Second, when it is, you can always use <code>RewriteRule</code> (Apache) | |
62 | + <code>rewrite</code> (nginx), or the equivalent in your favorite Web | |
63 | + server, and code your templates to use the SEO-friendly version of your | |
64 | + URL’s. With TinCan sitting behind a good, production-grade web server, you | |
65 | + get the best of both worlds: fast, simple deployment when you want it, and | |
66 | + SEO-friendly URL’s when you want it. </p> | |
67 | + <h2>But What about Routing Things Based on Both Path and Method?</h2> | |
68 | + <p>That’s easy enough to do, as TinCan is implemented on top of Bottle. You | |
69 | + can add your Bottle routes, using the <code>@route</code> decorator on | |
70 | + your controller methods, same as always. Just stick them in the same | |
71 | + start-up script you use to launch your TinCan files.</p> | |
72 | + <p>If for some reason you don’t want to mess with manually creating routes | |
73 | + and associating them with controllers in Bottle (even in cases like this | |
74 | + where it arguably makes sense), and want to do <em>everything</em> the | |
75 | + TinCan way, you can create a set of hidden (using the <code>#hidden</code> | |
76 | + directive) pages and a main dummy page whose code-behind forwards (<code>page.request.app.forward</code>) | |
77 | + to the appropriate hidden page depending on request method. </p> | |
78 | + <h2>What about Launching Multiple TinCan Webapps?</h2> | |
79 | + <p>It works just as well (and just as poorly) as launching multiple Bottle | |
80 | + webapps. Note that the big limitation here is Python’s module subsystem; | |
81 | + there is only one. Thus, all webapps share the same module path. There is | |
82 | + no way to have one webapp using an older version of a given module served | |
83 | + by the same server as another using a newer version, save renaming one of | |
84 | + the modules. This is a Python issue, not a Bottle issue or a TinCan issue.</p> | |
85 | + <p>Note that TinCan bypasses the Python module cache and manages its own | |
86 | + importing of code-behind files, so there is no problem if you have | |
87 | + multiple webapps using the same relative URL paths. TinCan will keep all | |
88 | + those code-behind files straight; it will not confuse one webapp’s <span | |
89 | + class="kbd">/index.py</span> with another’s.</p> | |
90 | + <h2>What about Bottle Plugins?</h2> | |
91 | + <p>I am working on adding support for these.</p> | |
92 | + </body> | |
93 | +</html> |
@@ -0,0 +1,393 @@ | ||
1 | +#!/usr/bin/env python3 | |
2 | +# -*- coding: utf-8 -*- | |
3 | +# As with Bottle, it's all in one big, ugly file. For now. | |
4 | + | |
5 | +# I m p o r t s | |
6 | + | |
7 | +import os, sys | |
8 | +import ast | |
9 | +import binascii | |
10 | +from base64 import b16encode, b16decode | |
11 | +import importlib, py_compile | |
12 | +import io | |
13 | + | |
14 | +import bottle | |
15 | + | |
16 | +# C l a s s e s | |
17 | + | |
18 | +# Exceptions | |
19 | + | |
20 | +class TinCanException(Exception): | |
21 | + """ | |
22 | + The parent class of all exceptions we raise. | |
23 | + """ | |
24 | + pass | |
25 | + | |
26 | +class TemplateHeaderException(TinCanException): | |
27 | + """ | |
28 | + Raised upon encountering a syntax error in the template headers. | |
29 | + """ | |
30 | + def __init__(self, message, line): | |
31 | + super().__init__(message, line) | |
32 | + self.message = message | |
33 | + self.line = line | |
34 | + | |
35 | + def __str__(self): | |
36 | + return "Line {0}: {1}".format(self.line, self.message) | |
37 | + | |
38 | +class ForwardException(TinCanException): | |
39 | + """ | |
40 | + Raised to effect the flow control needed to do a forward (server-side | |
41 | + redirect). It is ugly to do this, but other Python frameworks do and | |
42 | + there seems to be no good alternative. | |
43 | + """ | |
44 | + def __init__(self, target): | |
45 | + self.target = target | |
46 | + | |
47 | +class TinCanError(TinCanException): | |
48 | + """ | |
49 | + General-purpose exception thrown by TinCan when things go wrong, often | |
50 | + when attempting to launch webapps. | |
51 | + """ | |
52 | + pass | |
53 | + | |
54 | +# Template (.pspx) files. These are standard templates for a supported | |
55 | +# template engine, but with an optional set of header lines that begin | |
56 | +# with '#'. | |
57 | + | |
58 | +class TemplateFile(object): | |
59 | + """ | |
60 | + Parse a template file into a header part and the body part. The header | |
61 | + is always a leading set of lines, each starting with '#', that is of the | |
62 | + same format regardless of the template body. The template body varies | |
63 | + depending on the selected templating engine. The body part has | |
64 | + each header line replaced by a blank line. This preserves the overall | |
65 | + line numbering when processing the body. The added newlines are normally | |
66 | + stripped out before the rendered page is sent back to the client. | |
67 | + """ | |
68 | + def __init__(self, raw, encoding='utf-8'): | |
69 | + if isinstance(raw, io.TextIOBase): | |
70 | + self._do_init(raw) | |
71 | + elif isinstance(raw, str): | |
72 | + with open(raw, "r", encoding=encoding) as fp: | |
73 | + self._do_init(fp) | |
74 | + else: | |
75 | + raise TypeError("Expecting a string or Text I/O object.") | |
76 | + | |
77 | + def _do_init(self, fp): | |
78 | + self._hbuf = [] | |
79 | + self._bbuf = [] | |
80 | + self._state = self._header | |
81 | + while True: | |
82 | + line = fp.readline() | |
83 | + if line == '': | |
84 | + break | |
85 | + self._state(line) | |
86 | + self.header = ''.join(self._hbuf) | |
87 | + self.body = ''.join(self._bbuf) | |
88 | + | |
89 | + def _header(self, line): | |
90 | + if not line.startswith('#'): | |
91 | + self._state = self._body | |
92 | + self._state(line) | |
93 | + return | |
94 | + self._hbuf.append(line) | |
95 | + self._bbuf.append("\n") | |
96 | + | |
97 | + def _body(self, line): | |
98 | + self._bbuf.append(line) | |
99 | + | |
100 | +class TemplateHeader(object): | |
101 | + """ | |
102 | + Parses and represents a set of header lines. | |
103 | + """ | |
104 | + _NAMES = [ "error", "forward", "methods", "python", "template" ] | |
105 | + _FNAMES = [ "hidden" ] | |
106 | + | |
107 | + def __init__(self, string): | |
108 | + # Initialize our state | |
109 | + for i in self._NAMES: | |
110 | + setattr(self, i, None) | |
111 | + for i in self._FNAMES: | |
112 | + setattr(self, i, False) | |
113 | + # Parse the string | |
114 | + count = 0 | |
115 | + nameset = set(self._NAMES + self._FNAMES) | |
116 | + seen = set() | |
117 | + lines = string.split("\n") | |
118 | + if lines and lines[-1] == "": | |
119 | + del lines[-1] | |
120 | + for line in lines: | |
121 | + # Get line | |
122 | + count += 1 | |
123 | + if not line.startswith("#"): | |
124 | + raise TemplateHeaderException("Does not start with '#'.", count) | |
125 | + try: | |
126 | + rna, rpa = line.split(maxsplit=1) | |
127 | + except ValueError: | |
128 | + raise TemplateHeaderException("Missing parameter.", count) | |
129 | + # Get name, ignoring remarks. | |
130 | + name = rna[1:] | |
131 | + if name == "rem": | |
132 | + continue | |
133 | + if name not in nameset: | |
134 | + raise TemplateHeaderException("Invalid directive: {0!r}".format(rna), count) | |
135 | + if name in seen: | |
136 | + raise TemplateHeaderException("Duplicate {0!r} directive.".format(rna), count) | |
137 | + seen.add(name) | |
138 | + # Flags | |
139 | + if name in self._FLAGS: | |
140 | + setattr(self, name, True) | |
141 | + continue | |
142 | + # Get parameter | |
143 | + param = rpa.strip() | |
144 | + for i in [ "'", '"']: | |
145 | + if param.startswith(i) and param.endswith(i): | |
146 | + param = ast.literal_eval(param) | |
147 | + break | |
148 | + # Update this object | |
149 | + setattr(self, name, param) | |
150 | + | |
151 | +# Support for Chameleon templates (the kind TinCan uses by default). | |
152 | + | |
153 | +class ChameleonTemplate(bottle.BaseTemplate): | |
154 | + def prepare(self, **options): | |
155 | + from chameleon import PageTemplate, PageTemplateFile | |
156 | + if self.source: | |
157 | + self.tpl = chameleon.PageTemplate(self.source, | |
158 | + encoding=self.encoding, **options) | |
159 | + else: | |
160 | + self.tpl = chameleon.PageTemplateFile(self.filename, | |
161 | + encoding=self.encoding, search_path=self.lookup, **options) | |
162 | + | |
163 | + def render(self, *args, **kwargs): | |
164 | + for dictarg in args: | |
165 | + kwargs.update(dictarg) | |
166 | + _defaults = self.defaults.copy() | |
167 | + _defaults.update(kwargs) | |
168 | + return self.tpl.render(**_defaults) | |
169 | + | |
170 | +chameleon_template = functools.partial(template, template_adapter=ChameleonTemplate) | |
171 | +chameleon_view = functools.partial(view, template_adapter=ChameleonTemplate) | |
172 | + | |
173 | +# Utility functions, used in various places. | |
174 | + | |
175 | +def _normpath(base, unsplit): | |
176 | + """ | |
177 | + Split, normalize and ensure a possibly relative path is absolute. First | |
178 | + argument is a list of directory names, defining a base. Second | |
179 | + argument is a string, which may either be relative to that base, or | |
180 | + absolute. Only '/' is supported as a separator. | |
181 | + """ | |
182 | + scratch = unsplit.strip('/').split('/') | |
183 | + if not unsplit.startswith('/'): | |
184 | + scratch = base + scratch | |
185 | + ret = [] | |
186 | + for i in scratch: | |
187 | + if i == '.': | |
188 | + continue | |
189 | + if i == '..': | |
190 | + ret.pop() # may raise IndexError | |
191 | + continue | |
192 | + ret.append(i) | |
193 | + return ret | |
194 | + | |
195 | +def _mangle(string): | |
196 | + """ | |
197 | + Turn a possibly troublesome identifier into a mangled one. | |
198 | + """ | |
199 | + first = True | |
200 | + ret = [] | |
201 | + for ch in string: | |
202 | + if ch == '_' or not (ch if first else "x" + ch).isidentifier(): | |
203 | + ret.append('_') | |
204 | + ret.append(b16encode(ch.encode("utf-8")).decode("us-ascii")) | |
205 | + else: | |
206 | + ret.append(ch) | |
207 | + first = False | |
208 | + return ''.join(ret) | |
209 | + | |
210 | +# The TinCan class. Simply a Bottle webapp that contains a forward method, so | |
211 | +# the code-behind can call request.app.forward(). | |
212 | + | |
213 | +class TinCan(bottle.Bottle): | |
214 | + def forward(self, target): | |
215 | + """ | |
216 | + Forward this request to the specified target route. | |
217 | + """ | |
218 | + source = bottle.request.environ['PATH_INFO'] | |
219 | + base = source.strip('/').split('/')[:-1] | |
220 | + try: | |
221 | + exc = ForwardException('/' + '/'.join(_normpath(base, target))) | |
222 | + except IndexError as e: | |
223 | + raise TinCanError("{0}: invalid forward to {1!r}".format(source, target)) from e | |
224 | + raise exc | |
225 | + | |
226 | +# Represents the code-behind of one of our pages. This gets subclassed, of | |
227 | +# course. | |
228 | + | |
229 | +class Page(object): | |
230 | + # Non-private things we refuse to export anyhow. | |
231 | + __HIDDEN = set([ "request", "response" ]) | |
232 | + | |
233 | + def __init__(self, req, resp): | |
234 | + """ | |
235 | + Constructor. This is a lightweight operation. | |
236 | + """ | |
237 | + self.request = req # app context is request.app in Bottle | |
238 | + self.response = resp | |
239 | + | |
240 | + def handle(self): | |
241 | + """ | |
242 | + This is the entry point for the code-behind logic. It is intended | |
243 | + to be overridden. | |
244 | + """ | |
245 | + pass | |
246 | + | |
247 | + def export(self): | |
248 | + """ | |
249 | + Export template variables. The default behavior is to export all | |
250 | + non-hidden non-callables that don't start with an underscore, | |
251 | + plus a an export named page that contains this object itself. | |
252 | + This method can be overridden if a different behavior is | |
253 | + desired. It should always return a dict or dict-like object. | |
254 | + """ | |
255 | + ret = { "page": self } # feature: will be clobbered if self.page exists | |
256 | + for name in dir(self): | |
257 | + if name in self.__HIDDEN or name.startswith('_'): | |
258 | + continue | |
259 | + value = getattr(self, name) | |
260 | + if callable(value): | |
261 | + continue | |
262 | + ret[name] = value | |
263 | + | |
264 | +# Represents a route in TinCan. Our launcher creates these on-the-fly based | |
265 | +# on the files it finds. | |
266 | + | |
267 | +class TinCanRoute(object): | |
268 | + """ | |
269 | + A route created by the TinCan launcher. | |
270 | + """ | |
271 | + def __init__(self, launcher, name, subdir): | |
272 | + self._plib = launcher.plib | |
273 | + self._fsroot = launcher.fsroot | |
274 | + self._urlroot = launcher.urlroot | |
275 | + self._name = name | |
276 | + self._python = name + ".py" | |
277 | + self._content = CONTENT | |
278 | + self._fspath = os.path.join(launcher.fsroot, *subdir, name + EXTENSION) | |
279 | + self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + EXTENSION) | |
280 | + self._origin = self._urlpath | |
281 | + self._subdir = subdir | |
282 | + self._seen = set() | |
283 | + self._class = None | |
284 | + self._tclass = launcher.tclass | |
285 | + self._app = launcher.app | |
286 | + | |
287 | + def launch(self, config): | |
288 | + """ | |
289 | + Launch a single page. | |
290 | + """ | |
291 | + # Build master and header objects, process #forward directives | |
292 | + hidden = None | |
293 | + while True: | |
294 | + self._template = TemplateFile(self._fspath) | |
295 | + self._header = TemplateHeader(self._template.header) | |
296 | + if hidden is None: | |
297 | + hidden = self._header.hidden | |
298 | + if self._header.forward is None: | |
299 | + break | |
300 | + self._redirect() | |
301 | + # If this is a hidden page, we ignore it for now, since hidden pages | |
302 | + # don't get routes made for them. | |
303 | + if hidden: | |
304 | + return | |
305 | + # Get methods for this route | |
306 | + if self._header.methods is None: | |
307 | + methods = [ 'GET' ] | |
308 | + else: | |
309 | + methods = [ i.upper() for i in self._header.methods.split() ] | |
310 | + # Process other header entries | |
311 | + if self._header.python is not None: | |
312 | + if not self._header.python.endswith('.py'): | |
313 | + raise TinCanError("{0}: #python files must end in .py", self._urlpath) | |
314 | + self._python = self._header.python | |
315 | + # Obtain a class object by importing and introspecting a module. | |
316 | + pypath = os.path.normpath(os.path.join(self._fsroot, *self._subdir, self._python)) | |
317 | + pycpath = pypath + 'c' | |
318 | + try: | |
319 | + pyctime = os.stat(pycpath).st_mtime | |
320 | + except OSError: | |
321 | + pyctime = 0 | |
322 | + if pyctime < os.stat(pypath).st_mtime: | |
323 | + try: | |
324 | + py_compile.compile(pypath, cfile=pycpath) | |
325 | + except Exception as e: | |
326 | + raise TinCanError("{0}: error compiling".format(pypath)) from e | |
327 | + try: | |
328 | + self._mangled = self._manage_module() | |
329 | + spec = importlib.util.spec_from_file_location(_mangle(self._name), cpath) | |
330 | + mod = importlib.util.module_from_spec(spec) | |
331 | + spec.loader.exec_module(mod) | |
332 | + except Exception as e: | |
333 | + raise TinCanError("{0}: error importing".format(pycpath)) from e | |
334 | + self._class = None | |
335 | + for i in dir(mod): | |
336 | + v = getattr(mod, i) | |
337 | + if issubclass(v, Page): | |
338 | + if self._class is not None: | |
339 | + raise TinCanError("{0}: contains multiple Page classes", pypath) | |
340 | + self._class = v | |
341 | + # Build body object (Chameleon template) | |
342 | + self._body = self._tclass(source=self._template.body) | |
343 | + self._body.prepare() | |
344 | + # Register this thing with Bottle | |
345 | + print("adding route:", self._origin) # debug | |
346 | + self._app.route(self._origin, methods, self) | |
347 | + | |
348 | + def _splitpath(self, unsplit): | |
349 | + return _normpath(self._subdir, unsplit) | |
350 | + | |
351 | + def _redirect(self): | |
352 | + if self._header.forward in self._seen: | |
353 | + raise TinCanError("{0}: #forward loop".format(self._origin)) | |
354 | + self._seen.add(self._header.forward) | |
355 | + try: | |
356 | + rlist = self._splitpath(self._header.forward) | |
357 | + rname = rlist.pop() | |
358 | + except IndexError as e: | |
359 | + raise TinCanError("{0}: invalid #forward".format(self._urlpath)) from e | |
360 | + name, ext = os.path.splitext(rname)[1] | |
361 | + if ext != EXTENSION: | |
362 | + raise TinCanError("{0}: invalid #forward".format(self._urlpath)) | |
363 | + self._subdir = rlist | |
364 | + self._python = name + ".py" | |
365 | + self._fspath = os.path.join(self._fsroot, *self._subdir, rname) | |
366 | + self._urlpath = self._urljoin(*self._subdir, rname) | |
367 | + | |
368 | + def _urljoin(self, *args): | |
369 | + args = list(args) | |
370 | + if args[0] == '/': | |
371 | + args[0] = '' | |
372 | + return '/'.join(args) | |
373 | + | |
374 | + def __call__(self, request): | |
375 | + """ | |
376 | + This gets called by the framework AFTER the page is launched. | |
377 | + """ | |
378 | + ### needs to honor self._header.error if set | |
379 | + mod = importlib.import_module(self._mangled) | |
380 | + cls = getattr(mod, _CLASS) | |
381 | + obj = cls(request) | |
382 | + return Response(self._body.render(**self._mkdict(obj)).lstrip("\n"), | |
383 | + content_type=self._content) | |
384 | + | |
385 | + def _mkdict(self, obj): | |
386 | + ret = {} | |
387 | + for name in dir(obj): | |
388 | + if name.startswith('_'): | |
389 | + continue | |
390 | + value = getattr(obj, name) | |
391 | + if not callable(value): | |
392 | + ret[name] = value | |
393 | + return ret |