Attachment 'LookupDictPagesAndSort.py'
Download 1 """
2 MoinMoin - LookupDictPagesAndSort Macro
3 A line-oriented search macro over multiple pages, with sorting
4
5 @copyright: Jonas Smedegaard <dr@jones.dk>
6 @license: GPL
7
8 Based heavily on SearchInPagesAndSort
9 by Pascal Bauermeister <pascal DOT bauermeister AT hispeed DOT ch>
10
11 Updates:
12 * [v0.3.4.4] Jonas Sun Nov 12 19:00:39 UTC 2006
13 * Rename LookupPagesAndSort -> LookupDictPagesAndSort everywhere.
14 * New feature: SortKey can now be a dict lookup too. Same syntax: sk="@PN?Definition@"
15 * Today is my birthday: Congratulations! :-)
16
17 * [v0.3.4.3] Jonas Wed Nov 23 15:22:27 CET 2005
18 * Ignore empty LookupText values (continue loop rather than break).
19 * Adjust a variable name for a smaller SearchInPlacesAndSort diff.
20 * Allow caching of the page (let's see if it causes any trouble).
21 * Correct references to name of script itself.
22 * Replace examples with ones that make better sense.
23 * New feature: Pages can now be a group-page lookup: Prepend a "+".
24
25 * [v0.3.4.2] Jonas Fri Nov 18 17:27:01 CET 2005
26 * Replace SearchString (regexp) with LookupString (dict)
27 * Simplify heading_text and keyval loops (always one per page now)
28 * Decode UTF-8 input in regexp
29
30 * [v0.3.4.1] Jonas Fri Nov 18 17:03:26 CET 2005
31 * Add dict lookup. Syntax: @PN?Definition@
32 * Drop NbSubs and MoreSubsText support
33
34 * [v0.3.4] Pascal Sat Mar 5 17:53:08 CET 2005
35 * MoinMoin 1.3.x _and_ 1.2.x compatible
36 * Added arguments: Format, HeaderFormat and FormatSort
37
38 * [v0.3.3] Pascal
39 * Fixed a security hole (eval used for arguments parsing)
40 * Added argument: ExcludePages=regex
41
42 * [v0.3.2] Pascal
43 * Use StringIO instead of cStringIO, for unicode compatibility
44
45 * [v0.3.1] Pascal Sat Nov 6 16:03:01 CET 2004
46 * Added NoText, RawText, NbSubs and MoreSubsText arguments
47
48 * [v0.3.1] Pascal Mon Aug 30 21:27:36 CEST 2004
49 * Corrected bug: did not work well with multiple pages hit.
50 Bug reported by Craig Johnson.
51 It worked in 0.2.x because one bug corrected another one...
52 * If args are not a kw list (e.g. old macro form) inserts usage in html
53 page (brutal, but we really don't want to support the old form any more)
54
55 * [v0.3.0] Pascal Wed Aug 18 15:39:54 CEST 2004
56 * macro arguments are now passed as a list of KEYWORD=VALUE
57 * ACL is handled
58 * new options: Reverse and NoHeader
59
60 * [v0.2.4] Pascal Mon Jul 19 23:40:54 CEST 2004
61 * Comparisons to None use the 'is' and 'is not' operator (nicer)
62 * Use get() for dict lookup w/ default value
63 * Do not quote args and retry to compile if they are not valid regexes
64 * Corrected usage samples in the comment below
65
66 * [v0.2.3] Pascal Sun Jul 18 13:45:46 CEST 2004
67 Avoid endless recursion when matching page contains this macro
68
69 * [v0.2.2] Fri Jul 16 14:43:23 CEST 2004
70 * Use Request.redirect(). Thanks to Craig Johnson <cpjohnson AT edcon DOT
71 co DOT za>
72 and Thomas Waldmann <tw DASH public AT g m x DOT d e>.
73 * No more unused imports.
74 * Catch only expected exceptions.
75
76 * [v0.2.1] Mon Jun 7 11:54:52 CEST 2004
77 * options: links, heading
78 * works now with MoinMoin Release 1.2 too
79
80 * [v0.1.1] Wed Oct 29 14:48:02 CET 2003
81 works with MoinMoin Release 1.1 [Revision 1.173] and Python 2.3.2
82
83 * [v0.1.0] 2003/04/24 10:32:04
84 Original version
85
86 ----
87
88 Usage:
89 [[ LookupDictPagesAndSort ]]
90 [[ LookupDictPagesAndSort (KEYWORD=VALUE [, ...] ) ]]
91
92 Lookup 'lookuptext' dict definitions in pages matching 'pages' regex, and
93 sort the found lines (=hits) in this order:
94 1) substring of the hit matching 'sortkey'; group same matches of
95 'sortkey' by a header
96 2) substring of the hit matching 'lookuptext'
97 3) the hit itself
98
99 If no arguments are given, the usage is inserted in the HTML result.
100 Possible keywords:
101
102 Help = 0, 1, 2 Displays 1:short or 2:full help in the page.
103 Default: 0 (i.e. no help).
104
105 Pages = 'PAGES REGEX' Pages in which the text is sought. If
106 or empty (default) search in the current page
107 '+PageGroup' and defaults 'NoLinks' to 1. If starting with
108 "+" then a single PageGroup page is looked up.
109 Default: empty (i.e. current page).
110
111 ExcludePages = 'PAGES REGEX' Exclude these pages (i.e. remove these pages
112 from the list collected by 'Pages').
113 Default: empty (i.e. don't exclude any).
114
115 LookupText = 'TEXT DICT' To lookup definition in matching pages.
116 Mandatory!
117
118 SortKey = 'TEXT REGEX' Criterion to sort matching lines (=hits).
119 or Default: empty (i.e. no sorting).
120 '@PN?Definition@'
121
122 Heading = 'TEXT REGEX' Follow each hit by the text maching Regex,
123 that preceeds the hit in its source page.
124 Default: empty (i.e. no headings).
125
126 UnassignedText = 'WIKI TEXT' Header for hits not matching the sort key.
127 Default: '[unassigned]'.
128
129 Reverse = 0 or 1 Reverse-sort the hits.
130 Default: 0 (i.e. forward sort).
131
132 RawText = 0 or 1 Do not format found text.
133 Default: 0 (i.e. formatted).
134
135 Format = 'STRING' Explicitely format the output using this
136 string, which can contain wiki formatting
137 as well as these tokens:
138 @KT@ : text matching 'SortKey'
139 @LT@ : text matching 'LookupText'
140 @FT@ : line of text
141 @PN@ : page name
142 @HT@ : heading text
143 @@ : the '@' character
144 \\n : newline (of wiki source text).
145
146 Each token can contain a regex acting as
147 a filter for displaying the value, e.g:
148 @FT:{[123]}@ displays the prio smiley
149
150 Multiple groups can be defined, in which
151 case the text matching them will be
152 displayed, e.g:
153 @FT:{[123]}(.*)@ displays text after prio
154
155 Default: '' (i.e. auto-formatting).
156
157 HeaderFormat = 'STRING' If specified, use this instead of 'Format'
158 for headers.
159 Default: '' (i.e. do not display headers).
160
161 FormatSort = 0 or 1 If 1, sort the output generated by 'Format'
162 (if 'Reverse' is 1, reverse-sort). If 0,
163 leave the output sorted by the 'SortKey'
164 criterion (if specified).
165 Default: 0 (i.e. no sorting).
166
167 NoHeader = 0 or 1 Disable showing the headers as subtitles.
168 Default: 0 (i.e. show headers).
169
170 NoLinks = 0 or 1 Disable following each hit by a link to its
171 page.
172 Default: 0 (i.e. show links) or 1 if
173 'Pages' is omitted.
174
175 NoPageText = 'HTML TEXT' Text displayed if no page match 'Pages'.
176 Default: an error message w/ Page regex
177
178 NoText = 0 or 1 Disables showing the found text.
179 Default: 0 (i.e. show found text).
180
181 Keywords can be also given in upper or lower cases, or abbreviated.
182 Example: LookupText, lookuptext, LOOKUPTEXT, lt, LT, Pages, p, etc.
183
184 ----
185
186 Sample 1:
187
188 Given a page named 'AnInterestingBook':
189 = A rather interesting Book =
190 == Bibliographical facts ==
191 Title:: A rather interesting Book
192 Author:: A. Man
193 Publisher:: Cool Publishing Corp.
194 == Comments ==
195 I really think that this book is worth a read.
196
197 I'd even wanna lend out my copy if needed!
198
199 == Status ==
200 Owner:: Jonas Smedegaard
201 Availability:: Lend out to Jack the Ripper
202
203 ...and a page named 'AnotherInterestingBook':
204 = Another interesting Book =
205 == Bibliographical facts ==
206 Title:: Another interesting Book
207 Author:: A. Man
208 Publisher:: Cool Publishing Corp.
209 == Comments ==
210 This is the sequel to AnInterestingBook - also worth a read.
211
212 == Status ==
213 Owner:: Jonas Smedegaard
214 Availability:: Available - call me if interested in lending it
215
216 ...and a page named 'AnotherBoringBook':
217 = A boring Book =
218 == Bibliographical facts ==
219 Title:: A pretty boring Book
220 Author:: Some Fool
221 Publisher:: Lousy Publishing Corp.
222 == Comments ==
223 Don't waste time on this book.
224
225 I was stupid enough to buy it once, but won't even lend it out!
226
227 == Status ==
228 Owner:: Jonas Smedegaard
229 Availability::
230
231 ...and the wiki setup to include books as dict pages:
232 page_dict_regex = u'[a-z0-9](Book|Dict)$'
233
234 ...using the macro in a page named 'BookOverview' like this:
235 = Known books =
236 [[LookupDictPagesAndSort(pages=".*Book$", lookuptext="Title")]]
237
238 = Book availability =
239 [[LookupDictPagesAndSort(pages=".*Book$", lookuptext="Availability")]]
240
241 ...will give this output (note: _text_ are links):
242 Known books
243 * A. Man
244 * A rather interesting Book _AnInterestingBook_
245 * A rather interesting Book _AnotherInterestingBook_
246 * Some Fool
247 * A pretty boring Book _AnotherBoringBook_
248
249 Book Availability
250 * Lend out to Jack the Ripper _AnInterestingBook_
251 * Available - call me if interested in lending it _AnotherInterestingBook_
252
253
254 Sample 2:
255
256 Given a page /MyDict containing:
257 == Contact info ==
258 FirstName:: Jonas
259 FullName:: Jonas "dr. Jones" Smedegaard
260 Phone:: +45 40843136
261 Email:: dr@jones.dk
262 == Photo gallery ==
263 PhotoThumbnail:: http://dr.jones.dk/images/me/kp_bricks_thumb.jpg
264 PhotoPortrait:: http://dr.jones.dk/images/me/kp_bricks.jpg
265
266 ...the following macro call in another page:
267 [[LookupDictPagesAndSort(lookuptext="+WikiEditorsGroup", DictPage="/MyDict", LookupText="Email", Format=" * @PN?PhotoThumbnail@ [mailto:@PN?Email@ @PN?FirstName@]\\n")]])]]
268
269 ...will produce a list of images and email references for me and all other editors.
270 """
271
272 # Imports
273 import re, sys, StringIO, urllib
274 from string import ascii_lowercase, maketrans
275 from MoinMoin import config, wikiutil, version
276 from MoinMoin.Page import Page
277 from MoinMoin.parser import wiki
278
279 before_1_3 = version.release < '1.3'
280
281 #Dependencies = ["time"] # macro cannot be cached
282
283 _recursions = 0
284 FAKETRANS = maketrans ("","")
285
286
287 class _Error (Exception):
288 pass
289
290
291 def execute (macro, text, args_re=None):
292
293 global _recursions
294 if _recursions: return ''
295
296 _recursions += 1
297 try: res = _execute (macro, text)
298 except _Error, msg:
299 _recursions = 0
300 return """
301 <p><strong class="error">
302 Error: macro LookupDictPagesAndSort: %s</strong> </p>
303 """ % msg
304
305 _recursions -=1
306 return res
307
308
309 def _delparam (keyword, params):
310 value = params [keyword]
311 del params [keyword]
312 return value.decode("UTF-8")
313
314
315 def _param_get (params, spec, default):
316
317 """Returns the value for a parameter, if specified with one of
318 several acceptable keyword names, or returns its default value if
319 it is missing from the macro call. If the parameter is specified,
320 it is removed from the list, so that remaining params can be
321 signalled as unknown"""
322
323 # param name is litteral ?
324 if params.has_key (spec): return _delparam (spec, params)
325
326 # param name is all lower or all upper ?
327 lspec = spec.lower ()
328 if params.has_key (lspec): return _delparam (lspec, params)
329 uspec = spec.upper ()
330 if params.has_key (uspec): return _delparam (uspec, params)
331
332 # param name is abbreviated ?
333 cspec = spec [0].upper () + spec [1:] # capitalize 1st letter
334 cspec = cspec.translate (FAKETRANS, ascii_lowercase)
335 if params.has_key (cspec): return _delparam (cspec, params)
336 cspec = cspec.lower ()
337 if params.has_key (cspec): return _delparam (cspec, params)
338
339 # nope: return default value
340 return default
341
342
343 def _usage (full = False):
344
345 """Returns the interesting part of the module's doc"""
346
347 if full: return __doc__
348
349 lines = __doc__.replace ('\\n', '\\\\n'). splitlines ()
350 start = 0
351 end = len (lines)
352 for i in range (end):
353 if lines [i].strip ().lower () == "usage:":
354 start = i
355 break
356 for i in range (start, end):
357 if lines [i].startswith ('--'):
358 end = i
359 break
360 return '\n'.join (lines [start:end])
361
362
363 def _re_compile (text, name):
364 try:
365 return re.compile (text, re.IGNORECASE)
366 except Exception, msg:
367 raise _Error ("%s for regex argument %s: '%s'" % (msg, name, text))
368
369
370 last_request_h = None
371 last_pages_list = []
372
373 def _get_all_pages (request):
374 global last_request_h
375 global last_pages_list
376 request_h = hash (request)
377 if request_h != last_request_h:
378 if before_1_3: all_pages = wikiutil.getPageList (config.text_dir)
379 else: all_pages = request.rootpage.getPageList()
380 last_request_h = request_h
381 last_pages_list = all_pages
382 return last_pages_list
383
384
385 # The "raison d'etre" of this module
386 def _execute (macro, text):
387
388 result = ""
389
390 # new args syntax
391 try:
392 params = eval ("(lambda **opts: opts)(%s)" % text,
393 {'__builtins__': []}, {})
394 except Exception, msg:
395 raise _Error ("""<pre>malformed arguments list:
396 %s<br>cause:
397 %s
398 </pre>
399 <br> usage:
400 <pre>%s</pre>
401 """ % (text, msg, _usage () ) )
402
403 arg_text = _param_get (params, 'LookupText', None)
404 arg_pages = _param_get (params, 'Pages', '')
405 arg_excl_pages = _param_get (params, 'ExcludePages', '')
406 arg_dict = _param_get (params, 'DictPage', '')
407 arg_key = _param_get (params, 'SortKey', None)
408
409 opt_heading = _param_get (params, 'Heading', None)
410 opt_unassigned_text = _param_get (params, 'UnassignedText',
411 "[unassigned]")
412 opt_reverse = _param_get (params, 'Reverse', False)
413 opt_rawtext = _param_get (params, 'RawText', False)
414
415 opt_format = _param_get (params, 'Format', '')
416 opt_headerformat = _param_get (params, 'HeaderFormat', '')
417 opt_formatsort = _param_get (params, 'FormatSort', 0)
418
419 def_nolinks = (1,0) [len (arg_pages)>0]
420 opt_nolinks = _param_get (params, 'NoLinks', def_nolinks)
421 opt_noheader = _param_get (params, 'NoHeader', False)
422 opt_notext = _param_get (params, 'NoText', False)
423 opt_nopage = _param_get (params, 'NoPageText', None)
424 opt_help = _param_get (params, 'Help', 0)
425
426 # help ?
427 if opt_help:
428 return """
429 <p>
430 Macro LookupDictPagesAndSort usage:
431 <pre>%s</pre></p>
432 """ % _usage (opt_help==2)
433
434 # check the args a little bit
435 if len (params):
436 raise _Error ("""unknown argument(s): %s
437 <br> usage:
438 <pre>%s</pre>
439 """ % (`params.keys ()`, _usage () ) )
440
441 if arg_text is None:
442 raise _Error ("missing 'lookuptext' argument")
443
444 # empty page means this page; subpage are also handled
445 if len (arg_pages) == 0 or arg_pages.startswith ('/'):
446 arg_pages = macro.formatter.page.page_name + arg_pages
447
448 # get a list of pages matching the PageRegex
449 all_pages = _get_all_pages (macro.request)
450 if arg_pages [0]=="+":
451 hits = macro.request.dicts.members(arg_pages [1:])
452 else:
453 pages_re = _re_compile (arg_pages, 'Pages')
454 hits = filter (pages_re.search, all_pages)
455 if arg_excl_pages:
456 excl_pages_re = _re_compile (arg_excl_pages, 'ExcludePages')
457 hits = filter (lambda hit: not excl_pages_re.search (hit), hits)
458
459 if before_1_3:
460 # check ACL now (since we may end up with no pages)
461 if config.acl_enabled:
462 me = macro.request.user.name
463 def _check_page (page_name):
464 page = Page (page_name) # too bad we must instanciate...
465 return page.getACL ().may (macro.request, me, "read")
466 hits = filter (_check_page, hits)
467
468 # sort pages, check if we have pages
469 if len (hits) == 0:
470 if opt_nopage: return "%s" % opt_nopage
471 else:
472 raise _Error ("no page matching '%s'!" % arg_pages)
473 else: hits.sort ()
474
475 if arg_key is not None and not (arg_key.startswith('@PN?') and arg_key.endswith('@')):
476 key_re = _re_compile (arg_key, 'SortKey')
477
478 if opt_heading is not None:
479 heading_re = _re_compile (opt_heading, 'Heading')
480
481 # we will collect matching lines in each matching page
482 all_matches = []
483
484 # treat each found page
485 for page_name in hits:
486 heading_text = ""
487
488 # Set dict page to use for lookups
489 if len (arg_dict) == 0 or arg_dict.startswith ('/'):
490 dict_name = page_name + arg_dict
491 else:
492 dict_name = arg_dict
493
494 # lookup text
495 lookuptext = macro.request.dicts.dict(dict_name).get(arg_text,'')
496 if not lookuptext: continue
497
498 # text is found; now search for heading
499 if opt_heading is not None:
500 heading_match = heading_re.search (lookuptext)
501 if heading_match:
502 heading_text = heading_match.group (0)
503
504 # find the sort key
505 keyval = ""
506 if arg_key is not None:
507 if arg_key.startswith('@PN?') and arg_key.endswith('@'):
508 keydict = arg_key.strip ("@")
509 #FIXME: only cut out dict name here, and lookup after (non-hardcoded!) pagename is expanded
510 keyval = macro.request.dicts.dict(dict_name).get(keydict [3:],'')
511 else:
512 keymatch = key_re.search (lookuptext)
513 if keymatch:
514 keyval = keymatch.group (0)
515 else:
516 keyval = opt_unassigned_text
517
518 # store info
519 item = []
520 item.append (keyval) # key text
521 item.append (lookuptext) # lookup text
522 item.append (page_name) # page name
523 item.append (dict_name) # dict name
524 item.append (heading_text) # heading
525 all_matches.append (item)
526
527 # all pages handled
528
529 # prepare some formatting text
530 bullet_list_open = macro.formatter.bullet_list (1)
531 bullet_list_close = macro.formatter.bullet_list (0)
532 listitem_open = macro.formatter.listitem (1)
533 listitem_close = macro.formatter.listitem (0)
534
535 # now sort and format records
536 if not opt_notext: all_matches.sort ()
537 if opt_reverse: all_matches.reverse ()
538
539 # explicitely-formatted output
540 if opt_format:
541 block = ""
542 last_keytext = None
543 rx = re.compile (r'([^@]*?)(@[^@]*?@)')
544 pairs = re.findall (rx, opt_format+"@-@")
545 if opt_headerformat: hpairs = re.findall (rx, opt_headerformat+"@-@")
546 else: hpairs = None
547 rx2d = {}
548 for item in all_matches:
549 keytext, text, pagename, dict_name, heading_text = item
550 if keytext == last_keytext: plist = (pairs,)
551 elif hpairs: plist = (hpairs, pairs)
552 else: plist = (pairs,)
553 last_keytext = keytext
554 for p in plist:
555 for txt, token in p:
556 txt = txt.replace ("\\n", "\n")
557 if not token: continue
558 token = token.strip ("@")
559 block += txt
560 rx2 = None
561 if len (token)>2 and token [2]=="?":
562 #FIXME: only cut out dict name here, and lookup after (non-hardcoded!) pagename is expanded
563 token = macro.request.dicts.dict(dict_name).get(token [3:],'')
564 if len (token)>2 and token [2]==":":
565 token, rx2 = token [:2], token [3:]
566 if not rx2d.has_key (rx2): rx2d [rx2] = \
567 re.compile (rx2)
568 rx2 = rx2d [rx2]
569 token = token.replace ("\\n", "\n")
570 d = { "KT": keytext, "LT": text,
571 "PN": pagename, "HT": heading_text,
572 "": "@",
573 "-": "",
574 }
575 if rx2:
576 tx = d.get (token, None)
577 if tx:
578 tx = map ("".join, re.findall (rx2, tx)) [0]
579 else: tx = token
580 block += tx
581 else:
582 block += d.get (token, token)
583 if opt_formatsort:
584 lines = block.split ("\n")
585 lines.sort ()
586 if opt_reverse: lines.reverse ()
587 block = "\n".join (lines)
588 result += "\n%s\n" % _format (block, macro.request, macro.formatter)
589
590 # auto-formatted output treat records for output
591 else:
592 head_count = 0
593 result = result+"\n" + bullet_list_open
594 keyval = ""
595 last_pagename = ""
596
597 for item in all_matches:
598 keytext, text, pagename, dict_name, heading_text = item
599
600 if opt_notext:
601 text_fmtted = ""
602 if last_pagename == pagename: continue
603 else: last_pagename = pagename
604 elif opt_rawtext:
605 text_fmtted = wikiutil.escape (text)
606 else:
607 # parse the text (in wiki source format) and make HTML,
608 # after diverting sys.stdout to a string
609 text_fmtted = _format (text, macro.request, macro.formatter)
610 text_fmtted = text_fmtted.strip (' ') # preserve newlines
611
612 # empty text => drop this item
613 if len (text_fmtted)==0: continue
614
615 # insert heading (only if not yet done)
616 if not opt_noheader \
617 and arg_key is not None \
618 and keytext != keyval:
619 # this is a new heading
620 keyval = keytext
621 if head_count:
622 result = result+"\n " + bullet_list_close
623 result = result+"\n " + listitem_close
624 head_count = head_count +1
625 result = result+"\n " + listitem_open
626 result = result+ _format (keyval,
627 macro.request, macro.formatter)
628 result = result+"\n " + bullet_list_open
629
630 # correct the text format (berk)
631 if text_fmtted.startswith ("\n<p>"):
632 text_fmtted = text_fmtted [4:]
633 if text_fmtted.endswith ("</p>\n"):
634 text_fmtted = text_fmtted [:-5]
635 text_trailer = "\n</p>\n"
636 else: text_trailer = ""
637
638 # insert formatted text
639 result = result+"\n " + listitem_open
640 result = result + text_fmtted
641 if not opt_nolinks:
642 result = result + " <font size=-1>"
643 if arg_text:
644 if before_1_3:
645 pageurl = '%s?action=highlight&value=%s' % (
646 pagename,
647 urllib.quote_plus (re.escape (text)))
648 else:
649 pageurl = '%s?highlight=%s' % (
650 pagename,
651 urllib.quote_plus (re.escape (text)))
652
653 else: pageurl = wikiutil.quoteWikiname (pagename)
654 link_text = wikiutil.link_tag (macro.request,
655 pageurl, pagename)
656
657 result = result + link_text
658 result = result + "</font>"
659 if opt_heading is not None:
660 result = result + " <font size=-1>"
661 result = result + heading_text
662 result = result + "</font>"
663
664 result = result + text_trailer + "\n " + listitem_close
665
666 # all items done, close (hopefully) gracefully
667 if not opt_format:
668 if head_count:
669 result = result+"\n " + listitem_close
670 result = result+"\n " + bullet_list_close
671 if not opt_noheader and arg_key is not None:
672 result = result+"\n " + listitem_close
673 result = result+"\n" + bullet_list_close
674
675 # done
676 return result
677
678 def _format (src_text, request, formatter):
679 # parse the text (in wiki source format) and make HTML,
680 # after diverting sys.stdout to a string
681 str_out = StringIO.StringIO () # create str to collect output
682 request.redirect (str_out) # divert output to that string
683 # parse this line
684 wiki.Parser (src_text, request).format (formatter)
685 request.redirect () # restore output
686 return str_out.getvalue () # return what was generated
Attached Files
To refer to attachments on a page, use attachment:filename, as shown below in the list of files. Do NOT use the URL of the [get] link, since this is subject to change and can break easily.You are not allowed to attach a file to this page.