From 192c1d01fb1dac29d06b60c521846da7971f3960 Mon Sep 17 00:00:00 2001 From: Oleg Broytman Date: Tue, 25 Jun 2024 13:14:01 +0300 Subject: [PATCH] Version 3.2.0: Copy ``mailcap.py`` from Python 3.12 Because Python 3.13 dropped it. --- ANNOUNCE | 42 +----- ChangeLog | 4 + TODO | 3 - mimedecode/__version__.py | 2 +- mimedecode/mailcap_312.py | 302 ++++++++++++++++++++++++++++++++++++++ mimedecode/mimedecode.py | 5 +- setup.py | 1 + tox.ini | 2 +- 8 files changed, 316 insertions(+), 45 deletions(-) create mode 100644 mimedecode/mailcap_312.py diff --git a/ANNOUNCE b/ANNOUNCE index f34c5d1..39296e6 100644 --- a/ANNOUNCE +++ b/ANNOUNCE @@ -30,47 +30,11 @@ everything else. This is how it could be done: mimedecode -t application/pdf -t application/postscript -t text/plain -b text/html -B 'image/*' -i '*/*' -Version 3.1.0.post7: +WHAT'S NEW - GHActions: Test with Python 3.12. +Version 3.2.0: -Version 3.1.0.post6 (2023-09-17) - - GHActions: Ensure ``pip`` only if needed - - This is to work around a problem in conda with Python 3.7 - - it brings in wrong version of ``setuptools`` incompatible with Python 3.7. - -Version 3.1.0.post5 (2023-07-07) - - Install all Python and PyPy versions from ``conda-forge``. - -Version 3.1.0.post4 (2022-12-15) - - Use ``conda`` to install old Python versions. - -Version 3.1.0.post3 (2022-12-02) - - PyPy3. - -Version 3.1.0.post2 - - Python 3.11. - -Version 3.1.0.post1 (2021-09-24) - - GitHub Actions. - - Stop testing at Travis. - -Version 3.1.0 (2021-05-23) - - Convert mimedecode.docbook to reST. Generate html/man/text - using Sphinx. - - Replaced outdated and insecure `mktemp` with `NamedTemporaryFile`. - - Python 3.8, 3.9. + Copy ``mailcap.py`` from Python 3.12 because Python 3.13 dropped it. WHERE TO GET diff --git a/ChangeLog b/ChangeLog index 6a48f01..1d2a29f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,7 @@ +Version 3.2.0: + + Copy ``mailcap.py`` from Python 3.12 because Python 3.13 dropped it. + Version 3.1.0.post7: GHActions: Test with Python 3.12. diff --git a/TODO b/TODO index a9c7f22..8f6df9e 100644 --- a/TODO +++ b/TODO @@ -1,6 +1,3 @@ -Use https://pypi.org/project/standard-mailcap/ for Python 3.13+. - - Convert mimedecode.py library from global functions to a class. diff --git a/mimedecode/__version__.py b/mimedecode/__version__.py index cb16531..f4e4710 100644 --- a/mimedecode/__version__.py +++ b/mimedecode/__version__.py @@ -1,4 +1,4 @@ -__version__ = "3.1.0.post6" +__version__ = "3.2.0" __author__ = "Oleg Broytman " __copyright__ = "Copyright (C) 2001-2024 PhiloSoft Design" __license__ = "GNU GPL" diff --git a/mimedecode/mailcap_312.py b/mimedecode/mailcap_312.py new file mode 100644 index 0000000..6e69d48 --- /dev/null +++ b/mimedecode/mailcap_312.py @@ -0,0 +1,302 @@ +"""Mailcap file handling. See RFC 1524.""" + +import os +import warnings +import re + +__all__ = ["getcaps","findmatch"] + + +# _DEPRECATION_MSG = ('The {name} module is deprecated and will be removed in ' +# 'Python {remove}. See the mimetypes module for an ' +# 'alternative.') +# warnings._deprecated(__name__, _DEPRECATION_MSG, remove=(3, 13)) + + +def lineno_sort_key(entry): + # Sort in ascending order, with unspecified entries at the end + if 'lineno' in entry: + return 0, entry['lineno'] + else: + return 1, 0 + +_find_unsafe = re.compile(r'[^\xa1-\U0010FFFF\w@+=:,./-]').search + +class UnsafeMailcapInput(Warning): + """Warning raised when refusing unsafe input""" + + +# Part 1: top-level interface. + +def getcaps(): + """Return a dictionary containing the mailcap database. + + The dictionary maps a MIME type (in all lowercase, e.g. 'text/plain') + to a list of dictionaries corresponding to mailcap entries. The list + collects all the entries for that MIME type from all available mailcap + files. Each dictionary contains key-value pairs for that MIME type, + where the viewing command is stored with the key "view". + + """ + caps = {} + lineno = 0 + for mailcap in listmailcapfiles(): + try: + fp = open(mailcap, 'r') + except OSError: + continue + with fp: + morecaps, lineno = _readmailcapfile(fp, lineno) + for key, value in morecaps.items(): + if not key in caps: + caps[key] = value + else: + caps[key] = caps[key] + value + return caps + +def listmailcapfiles(): + """Return a list of all mailcap files found on the system.""" + # This is mostly a Unix thing, but we use the OS path separator anyway + if 'MAILCAPS' in os.environ: + pathstr = os.environ['MAILCAPS'] + mailcaps = pathstr.split(os.pathsep) + else: + if 'HOME' in os.environ: + home = os.environ['HOME'] + else: + # Don't bother with getpwuid() + home = '.' # Last resort + mailcaps = [home + '/.mailcap', '/etc/mailcap', + '/usr/etc/mailcap', '/usr/local/etc/mailcap'] + return mailcaps + + +# Part 2: the parser. +def readmailcapfile(fp): + """Read a mailcap file and return a dictionary keyed by MIME type.""" + warnings.warn('readmailcapfile is deprecated, use getcaps instead', + DeprecationWarning, 2) + caps, _ = _readmailcapfile(fp, None) + return caps + + +def _readmailcapfile(fp, lineno): + """Read a mailcap file and return a dictionary keyed by MIME type. + + Each MIME type is mapped to an entry consisting of a list of + dictionaries; the list will contain more than one such dictionary + if a given MIME type appears more than once in the mailcap file. + Each dictionary contains key-value pairs for that MIME type, where + the viewing command is stored with the key "view". + """ + caps = {} + while line := fp.readline(): + # Ignore comments and blank lines + if line[0] == '#' or line.strip() == '': + continue + nextline = line + # Join continuation lines + while nextline[-2:] == '\\\n': + nextline = fp.readline() + if not nextline: nextline = '\n' + line = line[:-2] + nextline + # Parse the line + key, fields = parseline(line) + if not (key and fields): + continue + if lineno is not None: + fields['lineno'] = lineno + lineno += 1 + # Normalize the key + types = key.split('/') + for j in range(len(types)): + types[j] = types[j].strip() + key = '/'.join(types).lower() + # Update the database + if key in caps: + caps[key].append(fields) + else: + caps[key] = [fields] + return caps, lineno + +def parseline(line): + """Parse one entry in a mailcap file and return a dictionary. + + The viewing command is stored as the value with the key "view", + and the rest of the fields produce key-value pairs in the dict. + """ + fields = [] + i, n = 0, len(line) + while i < n: + field, i = parsefield(line, i, n) + fields.append(field) + i = i+1 # Skip semicolon + if len(fields) < 2: + return None, None + key, view, rest = fields[0], fields[1], fields[2:] + fields = {'view': view} + for field in rest: + i = field.find('=') + if i < 0: + fkey = field + fvalue = "" + else: + fkey = field[:i].strip() + fvalue = field[i+1:].strip() + if fkey in fields: + # Ignore it + pass + else: + fields[fkey] = fvalue + return key, fields + +def parsefield(line, i, n): + """Separate one key-value pair in a mailcap entry.""" + start = i + while i < n: + c = line[i] + if c == ';': + break + elif c == '\\': + i = i+2 + else: + i = i+1 + return line[start:i].strip(), i + + +# Part 3: using the database. + +def findmatch(caps, MIMEtype, key='view', filename="/dev/null", plist=[]): + """Find a match for a mailcap entry. + + Return a tuple containing the command line, and the mailcap entry + used; (None, None) if no match is found. This may invoke the + 'test' command of several matching entries before deciding which + entry to use. + + """ + if _find_unsafe(filename): + msg = "Refusing to use mailcap with filename %r. Use a safe temporary filename." % (filename,) + warnings.warn(msg, UnsafeMailcapInput) + return None, None + entries = lookup(caps, MIMEtype, key) + # XXX This code should somehow check for the needsterminal flag. + for e in entries: + if 'test' in e: + test = subst(e['test'], filename, plist) + if test is None: + continue + if test and os.system(test) != 0: + continue + command = subst(e[key], MIMEtype, filename, plist) + if command is not None: + return command, e + return None, None + +def lookup(caps, MIMEtype, key=None): + entries = [] + if MIMEtype in caps: + entries = entries + caps[MIMEtype] + MIMEtypes = MIMEtype.split('/') + MIMEtype = MIMEtypes[0] + '/*' + if MIMEtype in caps: + entries = entries + caps[MIMEtype] + if key is not None: + entries = [e for e in entries if key in e] + entries = sorted(entries, key=lineno_sort_key) + return entries + +def subst(field, MIMEtype, filename, plist=[]): + # XXX Actually, this is Unix-specific + res = '' + i, n = 0, len(field) + while i < n: + c = field[i]; i = i+1 + if c != '%': + if c == '\\': + c = field[i:i+1]; i = i+1 + res = res + c + else: + c = field[i]; i = i+1 + if c == '%': + res = res + c + elif c == 's': + res = res + filename + elif c == 't': + if _find_unsafe(MIMEtype): + msg = "Refusing to substitute MIME type %r into a shell command." % (MIMEtype,) + warnings.warn(msg, UnsafeMailcapInput) + return None + res = res + MIMEtype + elif c == '{': + start = i + while i < n and field[i] != '}': + i = i+1 + name = field[start:i] + i = i+1 + param = findparam(name, plist) + if _find_unsafe(param): + msg = "Refusing to substitute parameter %r (%s) into a shell command" % (param, name) + warnings.warn(msg, UnsafeMailcapInput) + return None + res = res + param + # XXX To do: + # %n == number of parts if type is multipart/* + # %F == list of alternating type and filename for parts + else: + res = res + '%' + c + return res + +def findparam(name, plist): + name = name.lower() + '=' + n = len(name) + for p in plist: + if p[:n].lower() == name: + return p[n:] + return '' + + +# Part 4: test program. + +def test(): + import sys + caps = getcaps() + if not sys.argv[1:]: + show(caps) + return + for i in range(1, len(sys.argv), 2): + args = sys.argv[i:i+2] + if len(args) < 2: + print("usage: mailcap [MIMEtype file] ...") + return + MIMEtype = args[0] + file = args[1] + command, e = findmatch(caps, MIMEtype, 'view', file) + if not command: + print("No viewer found for", type) + else: + print("Executing:", command) + sts = os.system(command) + sts = os.waitstatus_to_exitcode(sts) + if sts: + print("Exit status:", sts) + +def show(caps): + print("Mailcap files:") + for fn in listmailcapfiles(): print("\t" + fn) + print() + if not caps: caps = getcaps() + print("Mailcap entries:") + print() + ckeys = sorted(caps) + for type in ckeys: + print(type) + entries = caps[type] + for e in entries: + keys = sorted(e) + for k in keys: + print(" %-15s" % k, e[k]) + print() + +if __name__ == '__main__': + test() diff --git a/mimedecode/mimedecode.py b/mimedecode/mimedecode.py index c23a88f..c5c80b0 100644 --- a/mimedecode/mimedecode.py +++ b/mimedecode/mimedecode.py @@ -216,7 +216,10 @@ caps = None # Globally stored mailcap database; initialized only if needed def decode_body(msg, s): "Decode body to plain text using first copiousoutput filter from mailcap" - import mailcap + try: + import mailcap + except ImportError: # Python 3.13 + from mimedecode import mailcap_312 as mailcap import tempfile global caps diff --git a/setup.py b/setup.py index a9d28cd..a4727f3 100755 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ setup( 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', ], packages=['mimedecode'], entry_points={ diff --git a/tox.ini b/tox.ini index bd38e03..647bdb2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 2.0 -envlist = py27,py3{4,5,6,7,8,9,10,11,12},pypy,pypy3 +envlist = py27,py3{4,5,6,7,8,9,10,11,12,13},pypy,pypy3 [testenv] deps = -- 2.39.5