Apply 67_update_handle_old_versions.patch
[mspang/vmailman.git] / Mailman / Utils.py
1 # Copyright (C) 1998-2006 by the Free Software Foundation, Inc.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software
15 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
16 # USA.
17
18
19 """Miscellaneous essential routines.
20
21 This includes actual message transmission routines, address checking and
22 message and address munging, a handy-dandy routine to map a function on all
23 the mailing lists, and whatever else doesn't belong elsewhere.
24
25 """
26
27 from __future__ import nested_scopes
28
29 import os
30 import re
31 import cgi
32 import sha
33 import time
34 import errno
35 import base64
36 import random
37 import urlparse
38 import htmlentitydefs
39 import email.Header
40 import email.Iterators
41 from email.Errors import HeaderParseError
42 from types import UnicodeType
43 from string import whitespace, digits
44 try:
45     # Python 2.2
46     from string import ascii_letters
47 except ImportError:
48     # Older Pythons
49     _lower = 'abcdefghijklmnopqrstuvwxyz'
50     ascii_letters = _lower + _lower.upper()
51
52 from Mailman import mm_cfg
53 from Mailman import Errors
54 from Mailman import Site
55 from Mailman.SafeDict import SafeDict
56 from Mailman.Logging.Syslog import syslog
57
58 try:
59     True, False
60 except NameError:
61     True = 1
62     False = 0
63
64 EMPTYSTRING = ''
65 UEMPTYSTRING = u''
66 NL = '\n'
67 DOT = '.'
68 IDENTCHARS = ascii_letters + digits + '_'
69
70 # Search for $(identifier)s strings, except that the trailing s is optional,
71 # since that's a common mistake
72 cre = re.compile(r'%\(([_a-z]\w*?)\)s?', re.IGNORECASE)
73 # Search for $$, $identifier, or ${identifier}
74 dre = re.compile(r'(\${2})|\$([_a-z]\w*)|\${([_a-z]\w*)}', re.IGNORECASE)
75
76
77 \f
78 def list_exists(listname):
79     """Return true iff list `listname' exists."""
80     # The existance of any of the following file proves the list exists
81     # <wink>: config.pck, config.pck.last, config.db, config.db.last
82     #
83     # The former two are for 2.1alpha3 and beyond, while the latter two are
84     # for all earlier versions.
85     basepath = Site.get_listpath(listname)
86     for ext in ('.pck', '.pck.last', '.db', '.db.last'):
87         dbfile = os.path.join(basepath, 'config' + ext)
88         if os.path.exists(dbfile):
89             return True
90     return False
91
92
93 def list_names():
94     """Return the names of all lists in default list directory."""
95     # We don't currently support separate listings of virtual domains
96     return Site.get_listnames()
97
98
99 \f
100 # a much more naive implementation than say, Emacs's fill-paragraph!
101 def wrap(text, column=70, honor_leading_ws=True):
102     """Wrap and fill the text to the specified column.
103
104     Wrapping is always in effect, although if it is not possible to wrap a
105     line (because some word is longer than `column' characters) the line is
106     broken at the next available whitespace boundary.  Paragraphs are also
107     always filled, unless honor_leading_ws is true and the line begins with
108     whitespace.  This is the algorithm that the Python FAQ wizard uses, and
109     seems like a good compromise.
110
111     """
112     wrapped = ''
113     # first split the text into paragraphs, defined as a blank line
114     paras = re.split('\n\n', text)
115     for para in paras:
116         # fill
117         lines = []
118         fillprev = False
119         for line in para.split(NL):
120             if not line:
121                 lines.append(line)
122                 continue
123             if honor_leading_ws and line[0] in whitespace:
124                 fillthis = False
125             else:
126                 fillthis = True
127             if fillprev and fillthis:
128                 # if the previous line should be filled, then just append a
129                 # single space, and the rest of the current line
130                 lines[-1] = lines[-1].rstrip() + ' ' + line
131             else:
132                 # no fill, i.e. retain newline
133                 lines.append(line)
134             fillprev = fillthis
135         # wrap each line
136         for text in lines:
137             while text:
138                 if len(text) <= column:
139                     line = text
140                     text = ''
141                 else:
142                     bol = column
143                     # find the last whitespace character
144                     while bol > 0 and text[bol] not in whitespace:
145                         bol -= 1
146                     # now find the last non-whitespace character
147                     eol = bol
148                     while eol > 0 and text[eol] in whitespace:
149                         eol -= 1
150                     # watch out for text that's longer than the column width
151                     if eol == 0:
152                         # break on whitespace after column
153                         eol = column
154                         while eol < len(text) and text[eol] not in whitespace:
155                             eol += 1
156                         bol = eol
157                         while bol < len(text) and text[bol] in whitespace:
158                             bol += 1
159                         bol -= 1
160                     line = text[:eol+1] + '\n'
161                     # find the next non-whitespace character
162                     bol += 1
163                     while bol < len(text) and text[bol] in whitespace:
164                         bol += 1
165                     text = text[bol:]
166                 wrapped += line
167             wrapped += '\n'
168             # end while text
169         wrapped += '\n'
170         # end for text in lines
171     # the last two newlines are bogus
172     return wrapped[:-2]
173
174
175 \f
176 def QuotePeriods(text):
177     JOINER = '\n .\n'
178     SEP = '\n.\n'
179     return JOINER.join(text.split(SEP))
180
181
182 # This takes an email address, and returns a tuple containing (user,host)
183 def ParseEmail(email):
184     user = None
185     domain = None
186     email = email.lower()
187     at_sign = email.find('@')
188     if at_sign < 1:
189         return email, None
190     user = email[:at_sign]
191     rest = email[at_sign+1:]
192     domain = rest.split('.')
193     return user, domain
194
195
196 def LCDomain(addr):
197     "returns the address with the domain part lowercased"
198     atind = addr.find('@')
199     if atind == -1: # no domain part
200         return addr
201     return addr[:atind] + '@' + addr[atind+1:].lower()
202
203
204 # TBD: what other characters should be disallowed?
205 _badchars = re.compile(r'[][()<>|;^,\000-\037\177-\377]')
206
207 def ValidateEmail(s):
208     """Verify that an email address isn't grossly evil."""
209     # Pretty minimal, cheesy check.  We could do better...
210     if not s or s.count(' ') > 0:
211         raise Errors.MMBadEmailError
212     if _badchars.search(s) or s[0] == '-':
213         raise Errors.MMHostileAddress, s
214     user, domain_parts = ParseEmail(s)
215     # This means local, unqualified addresses, are no allowed
216     if not domain_parts:
217         raise Errors.MMBadEmailError, s
218     if len(domain_parts) < 2:
219         raise Errors.MMBadEmailError, s
220
221
222 \f
223 # Patterns which may be used to form malicious path to inject a new
224 # line in the mailman error log. (TK: advisory by Moritz Naumann)
225 CRNLpat = re.compile(r'[^\x21-\x7e]')
226
227 def GetPathPieces(envar='PATH_INFO'):
228     path = os.environ.get(envar)
229     if path:
230         if CRNLpat.search(path):
231             path = CRNLpat.split(path)[0]
232             syslog('error', 'Warning: Possible malformed path attack.')
233         return [p for p in path.split('/') if p]
234     return None
235
236
237 \f
238 def ScriptURL(target, web_page_url=None, absolute=False):
239     """target - scriptname only, nothing extra
240     web_page_url - the list's configvar of the same name
241     absolute - a flag which if set, generates an absolute url
242     """
243     if web_page_url is None:
244         web_page_url = mm_cfg.DEFAULT_URL_PATTERN % get_domain()
245         if web_page_url[-1] <> '/':
246             web_page_url = web_page_url + '/'
247     fullpath = os.environ.get('REQUEST_URI')
248     if fullpath is None:
249         fullpath = os.environ.get('SCRIPT_NAME', '') + \
250                    os.environ.get('PATH_INFO', '')
251     baseurl = urlparse.urlparse(web_page_url)[2]
252     if not absolute and fullpath.endswith(baseurl):
253         # Use relative addressing
254         fullpath = fullpath[len(baseurl):]
255         i = fullpath.find('?')
256         if i > 0:
257             count = fullpath.count('/', 0, i)
258         else:
259             count = fullpath.count('/')
260         path = ('../' * count) + target
261     else:
262         path = web_page_url + target
263     return path + mm_cfg.CGIEXT
264
265
266 \f
267 def GetPossibleMatchingAddrs(name):
268     """returns a sorted list of addresses that could possibly match
269     a given name.
270
271     For Example, given scott@pobox.com, return ['scott@pobox.com'],
272     given scott@blackbox.pobox.com return ['scott@blackbox.pobox.com',
273                                            'scott@pobox.com']"""
274
275     name = name.lower()
276     user, domain = ParseEmail(name)
277     res = [name]
278     if domain:
279         domain = domain[1:]
280         while len(domain) >= 2:
281             res.append("%s@%s" % (user, DOT.join(domain)))
282             domain = domain[1:]
283     return res
284
285
286 \f
287 def List2Dict(L, foldcase=False):
288     """Return a dict keyed by the entries in the list passed to it."""
289     d = {}
290     if foldcase:
291         for i in L:
292             d[i.lower()] = True
293     else:
294         for i in L:
295             d[i] = True
296     return d
297
298
299 \f
300 _vowels = ('a', 'e', 'i', 'o', 'u')
301 _consonants = ('b', 'c', 'd', 'f', 'g', 'h', 'k', 'm', 'n',
302                'p', 'r', 's', 't', 'v', 'w', 'x', 'z')
303 _syllables = []
304
305 for v in _vowels:
306     for c in _consonants:
307         _syllables.append(c+v)
308         _syllables.append(v+c)
309 del c, v
310
311 def UserFriendly_MakeRandomPassword(length):
312     syls = []
313     while len(syls) * 2 < length:
314         syls.append(random.choice(_syllables))
315     return EMPTYSTRING.join(syls)[:length]
316
317
318 def Secure_MakeRandomPassword(length):
319     bytesread = 0
320     bytes = []
321     fd = None
322     try:
323         while bytesread < length:
324             try:
325                 # Python 2.4 has this on available systems.
326                 newbytes = os.urandom(length - bytesread)
327             except (AttributeError, NotImplementedError):
328                 if fd is None:
329                     try:
330                         fd = os.open('/dev/urandom', os.O_RDONLY)
331                     except OSError, e:
332                         if e.errno <> errno.ENOENT:
333                             raise
334                         # We have no available source of cryptographically
335                         # secure random characters.  Log an error and fallback
336                         # to the user friendly passwords.
337                         syslog('error',
338                                'urandom not available, passwords not secure')
339                         return UserFriendly_MakeRandomPassword(length)
340                 newbytes = os.read(fd, length - bytesread)
341             bytes.append(newbytes)
342             bytesread += len(newbytes)
343         s = base64.encodestring(EMPTYSTRING.join(bytes))
344         # base64 will expand the string by 4/3rds
345         return s.replace('\n', '')[:length]
346     finally:
347         if fd is not None:
348             os.close(fd)
349
350
351 def MakeRandomPassword(length=mm_cfg.MEMBER_PASSWORD_LENGTH):
352     if mm_cfg.USER_FRIENDLY_PASSWORDS:
353         return UserFriendly_MakeRandomPassword(length)
354     return Secure_MakeRandomPassword(length)
355
356
357 def GetRandomSeed():
358     chr1 = int(random.random() * 52)
359     chr2 = int(random.random() * 52)
360     def mkletter(c):
361         if 0 <= c < 26:
362             c += 65
363         if 26 <= c < 52:
364             #c = c - 26 + 97
365             c += 71
366         return c
367     return "%c%c" % tuple(map(mkletter, (chr1, chr2)))
368
369
370 \f
371 def set_global_password(pw, siteadmin=True):
372     if siteadmin:
373         filename = mm_cfg.SITE_PW_FILE
374     else:
375         filename = mm_cfg.LISTCREATOR_PW_FILE
376     # rw-r-----
377     omask = os.umask(026)
378     try:
379         fp = open(filename, 'w')
380         fp.write(sha.new(pw).hexdigest() + '\n')
381         fp.close()
382     finally:
383         os.umask(omask)
384
385
386 def get_global_password(siteadmin=True):
387     if siteadmin:
388         filename = mm_cfg.SITE_PW_FILE
389     else:
390         filename = mm_cfg.LISTCREATOR_PW_FILE
391     try:
392         fp = open(filename)
393         challenge = fp.read()[:-1]                # strip off trailing nl
394         fp.close()
395     except IOError, e:
396         if e.errno <> errno.ENOENT: raise
397         # It's okay not to have a site admin password, just return false
398         return None
399     return challenge
400
401
402 def check_global_password(response, siteadmin=True):
403     challenge = get_global_password(siteadmin)
404     if challenge is None:
405         return None
406     return challenge == sha.new(response).hexdigest()
407
408
409 \f
410 def websafe(s):
411     return cgi.escape(s, quote=True)
412
413
414 def nntpsplit(s):
415     parts = s.split(':', 1)
416     if len(parts) == 2:
417         try:
418             return parts[0], int(parts[1])
419         except ValueError:
420             pass
421     # Use the defaults
422     return s, 119
423
424
425 \f
426 # Just changing these two functions should be enough to control the way
427 # that email address obscuring is handled.
428 def ObscureEmail(addr, for_text=False):
429     """Make email address unrecognizable to web spiders, but invertable.
430
431     When for_text option is set (not default), make a sentence fragment
432     instead of a token."""
433     if for_text:
434         return addr.replace('@', ' at ')
435     else:
436         return addr.replace('@', '--at--')
437
438 def UnobscureEmail(addr):
439     """Invert ObscureEmail() conversion."""
440     # Contrived to act as an identity operation on already-unobscured
441     # emails, so routines expecting obscured ones will accept both.
442     return addr.replace('--at--', '@')
443
444
445 \f
446 class OuterExit(Exception):
447     pass
448
449 def findtext(templatefile, dict=None, raw=False, lang=None, mlist=None):
450     # Make some text from a template file.  The order of searches depends on
451     # whether mlist and lang are provided.  Once the templatefile is found,
452     # string substitution is performed by interpolation in `dict'.  If `raw'
453     # is false, the resulting text is wrapped/filled by calling wrap().
454     #
455     # When looking for a template in a specific language, there are 4 places
456     # that are searched, in this order:
457     #
458     # 1. the list-specific language directory
459     #    lists/<listname>/<language>
460     #
461     # 2. the domain-specific language directory
462     #    templates/<list.host_name>/<language>
463     #
464     # 3. the site-wide language directory
465     #    templates/site/<language>
466     #
467     # 4. the global default language directory
468     #    templates/<language>
469     #
470     # The first match found stops the search.  In this way, you can specialize
471     # templates at the desired level, or, if you use only the default
472     # templates, you don't need to change anything.  You should never modify
473     # files in the templates/<language> subdirectory, since Mailman will
474     # overwrite these when you upgrade.  That's what the templates/site
475     # language directories are for.
476     #
477     # A further complication is that the language to search for is determined
478     # by both the `lang' and `mlist' arguments.  The search order there is
479     # that if lang is given, then the 4 locations above are searched,
480     # substituting lang for <language>.  If no match is found, and mlist is
481     # given, then the 4 locations are searched using the list's preferred
482     # language.  After that, the server default language is used for
483     # <language>.  If that still doesn't yield a template, then the standard
484     # distribution's English language template is used as an ultimate
485     # fallback.  If that's missing you've got big problems. ;)
486     #
487     # A word on backwards compatibility: Mailman versions prior to 2.1 stored
488     # templates in templates/*.{html,txt} and lists/<listname>/*.{html,txt}.
489     # Those directories are no longer searched so if you've got customizations
490     # in those files, you should move them to the appropriate directory based
491     # on the above description.  Mailman's upgrade script cannot do this for
492     # you.
493     #
494     # The function has been revised and renamed as it now returns both the
495     # template text and the path from which it retrieved the template. The
496     # original function is now a wrapper which just returns the template text
497     # as before, by calling this renamed function and discarding the second
498     # item returned.
499     #
500     # Calculate the languages to scan
501     languages = []
502     if lang is not None:
503         languages.append(lang)
504     if mlist is not None:
505         languages.append(mlist.preferred_language)
506     languages.append(mm_cfg.DEFAULT_SERVER_LANGUAGE)
507     # Calculate the locations to scan
508     searchdirs = []
509     if mlist is not None:
510         searchdirs.append(mlist.fullpath())
511         searchdirs.append(os.path.join(mm_cfg.TEMPLATE_DIR, mlist.host_name))
512     searchdirs.append(os.path.join(mm_cfg.TEMPLATE_DIR, 'site'))
513     searchdirs.append(mm_cfg.TEMPLATE_DIR)
514     # Start scanning
515     fp = None
516     try:
517         for lang in languages:
518             for dir in searchdirs:
519                 filename = os.path.join(dir, lang, templatefile)
520                 try:
521                     fp = open(filename)
522                     raise OuterExit
523                 except IOError, e:
524                     if e.errno <> errno.ENOENT: raise
525                     # Okay, it doesn't exist, keep looping
526                     fp = None
527     except OuterExit:
528         pass
529     if fp is None:
530         # Try one last time with the distro English template, which, unless
531         # you've got a really broken installation, must be there.
532         try:
533             filename = os.path.join(mm_cfg.TEMPLATE_DIR, 'en', templatefile)
534             fp = open(filename)
535         except IOError, e:
536             if e.errno <> errno.ENOENT: raise
537             # We never found the template.  BAD!
538             raise IOError(errno.ENOENT, 'No template file found', templatefile)
539     template = fp.read()
540     fp.close()
541     text = template
542     if dict is not None:
543         try:
544             sdict = SafeDict(dict)
545             try:
546                 text = sdict.interpolate(template)
547             except UnicodeError:
548                 # Try again after coercing the template to unicode
549                 utemplate = unicode(template, GetCharSet(lang), 'replace')
550                 text = sdict.interpolate(utemplate)
551         except (TypeError, ValueError), e:
552             # The template is really screwed up
553             syslog('error', 'broken template: %s\n%s', filename, e)
554             pass
555     if raw:
556         return text, filename
557     return wrap(text), filename
558
559
560 def maketext(templatefile, dict=None, raw=False, lang=None, mlist=None):
561     return findtext(templatefile, dict, raw, lang, mlist)[0]
562
563
564 \f
565 ADMINDATA = {
566     # admin keyword: (minimum #args, maximum #args)
567     'confirm':     (1, 1),
568     'help':        (0, 0),
569     'info':        (0, 0),
570     'lists':       (0, 0),
571     'options':     (0, 0),
572     'password':    (2, 2),
573     'remove':      (0, 0),
574     'set':         (3, 3),
575     'subscribe':   (0, 3),
576     'unsubscribe': (0, 1),
577     'who':         (0, 0),
578     }
579
580 # Given a Message.Message object, test for administrivia (eg subscribe,
581 # unsubscribe, etc).  The test must be a good guess -- messages that return
582 # true get sent to the list admin instead of the entire list.
583 def is_administrivia(msg):
584     linecnt = 0
585     lines = []
586     for line in email.Iterators.body_line_iterator(msg):
587         # Strip out any signatures
588         if line == '-- ':
589             break
590         if line.strip():
591             linecnt += 1
592         if linecnt > mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES:
593             return False
594         lines.append(line)
595     bodytext = NL.join(lines)
596     # See if the body text has only one word, and that word is administrivia
597     if ADMINDATA.has_key(bodytext.strip().lower()):
598         return True
599     # Look at the first N lines and see if there is any administrivia on the
600     # line.  BAW: N is currently hardcoded to 5.  str-ify the Subject: header
601     # because it may be an email.Header.Header instance rather than a string.
602     bodylines = lines[:5]
603     subject = str(msg.get('subject', ''))
604     bodylines.append(subject)
605     for line in bodylines:
606         if not line.strip():
607             continue
608         words = [word.lower() for word in line.split()]
609         minargs, maxargs = ADMINDATA.get(words[0], (None, None))
610         if minargs is None and maxargs is None:
611             continue
612         if minargs <= len(words[1:]) <= maxargs:
613             # Special case the `set' keyword.  BAW: I don't know why this is
614             # here.
615             if words[0] == 'set' and words[2] not in ('on', 'off'):
616                 continue
617             return True
618     return False
619
620
621 \f
622 def GetRequestURI(fallback=None, escape=True):
623     """Return the full virtual path this CGI script was invoked with.
624
625     Newer web servers seems to supply this info in the REQUEST_URI
626     environment variable -- which isn't part of the CGI/1.1 spec.
627     Thus, if REQUEST_URI isn't available, we concatenate SCRIPT_NAME
628     and PATH_INFO, both of which are part of CGI/1.1.
629
630     Optional argument `fallback' (default `None') is returned if both of
631     the above methods fail.
632
633     The url will be cgi escaped to prevent cross-site scripting attacks,
634     unless `escape' is set to 0.
635     """
636     url = fallback
637     if os.environ.has_key('REQUEST_URI'):
638         url = os.environ['REQUEST_URI']
639     elif os.environ.has_key('SCRIPT_NAME') and os.environ.has_key('PATH_INFO'):
640         url = os.environ['SCRIPT_NAME'] + os.environ['PATH_INFO']
641     if escape:
642         return websafe(url)
643     return url
644
645
646 \f
647 # Wait on a dictionary of child pids
648 def reap(kids, func=None, once=False):
649     while kids:
650         if func:
651             func()
652         try:
653             pid, status = os.waitpid(-1, os.WNOHANG)
654         except OSError, e:
655             # If the child procs had a bug we might have no children
656             if e.errno <> errno.ECHILD:
657                 raise
658             kids.clear()
659             break
660         if pid <> 0:
661             try:
662                 del kids[pid]
663             except KeyError:
664                 # Huh?  How can this happen?
665                 pass
666         if once:
667             break
668
669 \f
670 def GetLanguageDescr(lang):
671     return mm_cfg.LC_DESCRIPTIONS[lang][0]
672
673
674 def GetCharSet(lang):
675     return mm_cfg.LC_DESCRIPTIONS[lang][1]
676
677 def IsLanguage(lang):
678     return mm_cfg.LC_DESCRIPTIONS.has_key(lang)
679
680
681 \f
682 def get_domain():
683     host = os.environ.get('HTTP_HOST', os.environ.get('SERVER_NAME'))
684     port = os.environ.get('SERVER_PORT')
685     # Strip off the port if there is one
686     if port and host.endswith(':' + port):
687         host = host[:-len(port)-1]
688     if mm_cfg.VIRTUAL_HOST_OVERVIEW and host:
689         return host.lower()
690     else:
691         # See the note in Defaults.py concerning DEFAULT_URL
692         # vs. DEFAULT_URL_HOST.
693         hostname = ((mm_cfg.DEFAULT_URL
694                      and urlparse.urlparse(mm_cfg.DEFAULT_URL)[1])
695                      or mm_cfg.DEFAULT_URL_HOST)
696         return hostname.lower()
697
698
699 def get_site_email(hostname=None, extra=None):
700     if hostname is None:
701         hostname = mm_cfg.VIRTUAL_HOSTS.get(get_domain(), get_domain())
702     if extra is None:
703         return '%s@%s' % (mm_cfg.MAILMAN_SITE_LIST, hostname)
704     return '%s-%s@%s' % (mm_cfg.MAILMAN_SITE_LIST, extra, hostname)
705
706
707 \f
708 # This algorithm crafts a guaranteed unique message-id.  The theory here is
709 # that pid+listname+host will distinguish the message-id for every process on
710 # the system, except when process ids wrap around.  To further distinguish
711 # message-ids, we prepend the integral time in seconds since the epoch.  It's
712 # still possible that we'll vend out more than one such message-id per second,
713 # so we prepend a monotonically incrementing serial number.  It's highly
714 # unlikely that within a single second, there'll be a pid wraparound.
715 _serial = 0
716 def unique_message_id(mlist):
717     global _serial
718     msgid = '<mailman.%d.%d.%d.%s@%s>' % (
719         _serial, time.time(), os.getpid(),
720         mlist.internal_name(), mlist.host_name)
721     _serial += 1
722     return msgid
723
724
725 # Figure out epoch seconds of midnight at the start of today (or the given
726 # 3-tuple date of (year, month, day).
727 def midnight(date=None):
728     if date is None:
729         date = time.localtime()[:3]
730     # -1 for dst flag tells the library to figure it out
731     return time.mktime(date + (0,)*5 + (-1,))
732
733
734 \f
735 # Utilities to convert from simplified $identifier substitutions to/from
736 # standard Python $(identifier)s substititions.  The "Guido rules" for the
737 # former are:
738 #    $$ -> $
739 #    $identifier -> $(identifier)s
740 #    ${identifier} -> $(identifier)s
741
742 def to_dollar(s):
743     """Convert from %-strings to $-strings."""
744     s = s.replace('$', '$$').replace('%%', '%')
745     parts = cre.split(s)
746     for i in range(1, len(parts), 2):
747         if parts[i+1] and parts[i+1][0] in IDENTCHARS:
748             parts[i] = '${' + parts[i] + '}'
749         else:
750             parts[i] = '$' + parts[i]
751     return EMPTYSTRING.join(parts)
752
753
754 def to_percent(s):
755     """Convert from $-strings to %-strings."""
756     s = s.replace('%', '%%').replace('$$', '$')
757     parts = dre.split(s)
758     for i in range(1, len(parts), 4):
759         if parts[i] is not None:
760             parts[i] = '$'
761         elif parts[i+1] is not None:
762             parts[i+1] = '%(' + parts[i+1] + ')s'
763         else:
764             parts[i+2] = '%(' + parts[i+2] + ')s'
765     return EMPTYSTRING.join(filter(None, parts))
766
767
768 def dollar_identifiers(s):
769     """Return the set (dictionary) of identifiers found in a $-string."""
770     d = {}
771     for name in filter(None, [b or c or None for a, b, c in dre.findall(s)]):
772         d[name] = True
773     return d
774
775
776 def percent_identifiers(s):
777     """Return the set (dictionary) of identifiers found in a %-string."""
778     d = {}
779     for name in cre.findall(s):
780         d[name] = True
781     return d
782
783
784 \f
785 # Utilities to canonicalize a string, which means un-HTML-ifying the string to
786 # produce a Unicode string or an 8-bit string if all the characters are ASCII.
787 def canonstr(s, lang=None):
788     newparts = []
789     parts = re.split(r'&(?P<ref>[^;]+);', s)
790     def appchr(i):
791         if i < 256:
792             newparts.append(chr(i))
793         else:
794             newparts.append(unichr(i))
795     while True:
796         newparts.append(parts.pop(0))
797         if not parts:
798             break
799         ref = parts.pop(0)
800         if ref.startswith('#'):
801             try:
802                 appchr(int(ref[1:]))
803             except ValueError:
804                 # Non-convertable, stick with what we got
805                 newparts.append('&'+ref+';')
806         else:
807             c = htmlentitydefs.entitydefs.get(ref, '?')
808             if c.startswith('#') and c.endswith(';'):
809                 appchr(int(ref[1:-1]))
810             else:
811                 newparts.append(c)
812     newstr = EMPTYSTRING.join(newparts)
813     if isinstance(newstr, UnicodeType):
814         return newstr
815     # We want the default fallback to be iso-8859-1 even if the language is
816     # English (us-ascii).  This seems like a practical compromise so that
817     # non-ASCII characters in names can be used in English lists w/o having to
818     # change the global charset for English from us-ascii (which I
819     # superstitiously think may have unintended consequences).
820     if lang is None:
821         charset = 'iso-8859-1'
822     else:
823         charset = GetCharSet(lang)
824         if charset == 'us-ascii':
825             charset = 'iso-8859-1'
826     return unicode(newstr, charset, 'replace')
827
828
829 # The opposite of canonstr() -- sorta.  I.e. it attempts to encode s in the
830 # charset of the given language, which is the character set that the page will
831 # be rendered in, and failing that, replaces non-ASCII characters with their
832 # html references.  It always returns a byte string.
833 def uncanonstr(s, lang=None):
834     if s is None:
835         s = u''
836     if lang is None:
837         charset = 'us-ascii'
838     else:
839         charset = GetCharSet(lang)
840     # See if the string contains characters only in the desired character
841     # set.  If so, return it unchanged, except for coercing it to a byte
842     # string.
843     try:
844         if isinstance(s, UnicodeType):
845             return s.encode(charset)
846         else:
847             u = unicode(s, charset)
848             return s
849     except UnicodeError:
850         # Nope, it contains funny characters, so html-ref it
851         return uquote(s)
852
853
854 def uquote(s):
855     a = []
856     for c in s:
857         o = ord(c)
858         if o > 127:
859             a.append('&#%3d;' % o)
860         else:
861             a.append(c)
862     # Join characters together and coerce to byte string
863     return str(EMPTYSTRING.join(a))
864
865
866 def oneline(s, cset):
867     # Decode header string in one line and convert into specified charset
868     try:
869         h = email.Header.make_header(email.Header.decode_header(s))
870         ustr = h.__unicode__()
871         line = UEMPTYSTRING.join(ustr.splitlines())
872         return line.encode(cset, 'replace')
873     except (LookupError, UnicodeError, ValueError, HeaderParseError):
874         # possibly charset problem. return with undecoded string in one line.
875         return EMPTYSTRING.join(s.splitlines())