• R/O
  • SSH

tincan: Commit

The TinCan Web Framework


Commit MetaInfo

Revisione726fafcffaca109693de3f0af2a6c5d9e8917d8 (tree)
Time2019-05-13 07:26:28
AuthorDavid Barts <n5jrn@me.c...>
CommiterDavid Barts

Log Message

For backup purposes, UNFINISHED!!

Change Summary

Incremental Difference

diff -r 000000000000 -r e726fafcffac README.html
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/README.html Sun May 12 15:26:28 2019 -0700
@@ -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>
diff -r 000000000000 -r e726fafcffac tincan.py
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/tincan.py Sun May 12 15:26:28 2019 -0700
@@ -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
Show on old repository browser