1import cgi
2
3__version__ = '1.6.0'
4__author__ = 'Joe Gregorio'
5__email__ = 'joe@bitworking.org'
6__license__ = 'MIT License'
7__credits__ = ''
8
9
10class MimeTypeParseException(ValueError):
11    pass
12
13
14def parse_mime_type(mime_type):
15    """Parses a mime-type into its component parts.
16
17    Carves up a mime-type and returns a tuple of the (type, subtype, params)
18    where 'params' is a dictionary of all the parameters for the media range.
19    For example, the media range 'application/xhtml;q=0.5' would get parsed
20    into:
21
22       ('application', 'xhtml', {'q', '0.5'})
23
24    :rtype: (str,str,dict)
25    """
26    full_type, params = cgi.parse_header(mime_type)
27    # Java URLConnection class sends an Accept header that includes a
28    # single '*'. Turn it into a legal wildcard.
29    if full_type == '*':
30        full_type = '*/*'
31
32    type_parts = full_type.split('/') if '/' in full_type else None
33    if not type_parts or len(type_parts) > 2:
34        raise MimeTypeParseException(
35            "Can't parse type \"{}\"".format(full_type))
36
37    (type, subtype) = type_parts
38
39    return (type.strip(), subtype.strip(), params)
40
41
42def parse_media_range(range):
43    """Parse a media-range into its component parts.
44
45    Carves up a media range and returns a tuple of the (type, subtype,
46    params) where 'params' is a dictionary of all the parameters for the media
47    range.  For example, the media range 'application/*;q=0.5' would get parsed
48    into:
49
50       ('application', '*', {'q', '0.5'})
51
52    In addition this function also guarantees that there is a value for 'q'
53    in the params dictionary, filling it in with a proper default if
54    necessary.
55
56    :rtype: (str,str,dict)
57    """
58    (type, subtype, params) = parse_mime_type(range)
59    params.setdefault('q', params.pop('Q', None))  # q is case insensitive
60    try:
61        if not params['q'] or not 0 <= float(params['q']) <= 1:
62            params['q'] = '1'
63    except ValueError:  # from float()
64        params['q'] = '1'
65
66    return (type, subtype, params)
67
68
69def quality_and_fitness_parsed(mime_type, parsed_ranges):
70    """Find the best match for a mime-type amongst parsed media-ranges.
71
72    Find the best match for a given mime-type against a list of media_ranges
73    that have already been parsed by parse_media_range(). Returns a tuple of
74    the fitness value and the value of the 'q' quality parameter of the best
75    match, or (-1, 0) if no match was found. Just as for quality_parsed(),
76    'parsed_ranges' must be a list of parsed media ranges.
77
78    :rtype: (float,int)
79    """
80    best_fitness = -1
81    best_fit_q = 0
82    (target_type, target_subtype, target_params) = \
83        parse_media_range(mime_type)
84
85    for (type, subtype, params) in parsed_ranges:
86
87        # check if the type and the subtype match
88        type_match = (
89            type in (target_type, '*') or
90            target_type == '*'
91        )
92        subtype_match = (
93            subtype in (target_subtype, '*') or
94            target_subtype == '*'
95        )
96
97        # if they do, assess the "fitness" of this mime_type
98        if type_match and subtype_match:
99
100            # 100 points if the type matches w/o a wildcard
101            fitness = type == target_type and 100 or 0
102
103            # 10 points if the subtype matches w/o a wildcard
104            fitness += subtype == target_subtype and 10 or 0
105
106            # 1 bonus point for each matching param besides "q"
107            param_matches = sum([
108                1 for (key, value) in target_params.items()
109                if key != 'q' and key in params and value == params[key]
110            ])
111            fitness += param_matches
112
113            # finally, add the target's "q" param (between 0 and 1)
114            fitness += float(target_params.get('q', 1))
115
116            if fitness > best_fitness:
117                best_fitness = fitness
118                best_fit_q = params['q']
119
120    return float(best_fit_q), best_fitness
121
122
123def quality_parsed(mime_type, parsed_ranges):
124    """Find the best match for a mime-type amongst parsed media-ranges.
125
126    Find the best match for a given mime-type against a list of media_ranges
127    that have already been parsed by parse_media_range(). Returns the 'q'
128    quality parameter of the best match, 0 if no match was found. This function
129    behaves the same as quality() except that 'parsed_ranges' must be a list of
130    parsed media ranges.
131
132    :rtype: float
133    """
134
135    return quality_and_fitness_parsed(mime_type, parsed_ranges)[0]
136
137
138def quality(mime_type, ranges):
139    """Return the quality ('q') of a mime-type against a list of media-ranges.
140
141    Returns the quality 'q' of a mime-type when compared against the
142    media-ranges in ranges. For example:
143
144    >>> quality('text/html','text/*;q=0.3, text/html;q=0.7,
145                  text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5')
146    0.7
147
148    :rtype: float
149    """
150    parsed_ranges = [parse_media_range(r) for r in ranges.split(',')]
151
152    return quality_parsed(mime_type, parsed_ranges)
153
154
155def best_match(supported, header):
156    """Return mime-type with the highest quality ('q') from list of candidates.
157
158    Takes a list of supported mime-types and finds the best match for all the
159    media-ranges listed in header. The value of header must be a string that
160    conforms to the format of the HTTP Accept: header. The value of 'supported'
161    is a list of mime-types. The list of supported mime-types should be sorted
162    in order of increasing desirability, in case of a situation where there is
163    a tie.
164
165    >>> best_match(['application/xbel+xml', 'text/xml'],
166                   'text/*;q=0.5,*/*; q=0.1')
167    'text/xml'
168
169    :rtype: str
170    """
171    split_header = _filter_blank(header.split(','))
172    parsed_header = [parse_media_range(r) for r in split_header]
173    weighted_matches = []
174    pos = 0
175    for mime_type in supported:
176        weighted_matches.append((
177            quality_and_fitness_parsed(mime_type, parsed_header),
178            pos,
179            mime_type
180        ))
181        pos += 1
182    weighted_matches.sort()
183
184    return weighted_matches[-1][0][0] and weighted_matches[-1][2] or ''
185
186
187def _filter_blank(i):
188    """Return all non-empty items in the list."""
189    for s in i:
190        if s.strip():
191            yield s
192