comparison tincan.py @ 0:e726fafcffac draft

For backup purposes, UNFINISHED!!
author David Barts <n5jrn@me.com>
date Sun, 12 May 2019 15:26:28 -0700
parents
children 94b36e721500
comparison
equal deleted inserted replaced
-1:000000000000 0:e726fafcffac
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