Merge lp:~leonardr/lazr.restful/include-html-field-representations into lp:lazr.restful
- include-html-field-representations
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 173 |
Proposed branch: | lp:~leonardr/lazr.restful/include-html-field-representations |
Merge into: | lp:lazr.restful |
Diff against target: |
954 lines (+402/-143) 10 files modified
src/lazr/restful/NEWS.txt (+6/-0) src/lazr/restful/_resource.py (+73/-80) src/lazr/restful/example/base/tests/field.txt (+36/-3) src/lazr/restful/interfaces/_rest.py (+5/-1) src/lazr/restful/testing/helpers.py (+12/-1) src/lazr/restful/testing/webservice.py (+12/-0) src/lazr/restful/tests/test_utils.py (+50/-0) src/lazr/restful/tests/test_webservice.py (+142/-57) src/lazr/restful/utils.py (+65/-0) src/lazr/restful/version.txt (+1/-1) |
To merge this branch: | bzr merge lp:~leonardr/lazr.restful/include-html-field-representations |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Curtis Hovey (community) | code | Approve | |
j.c.sackett (community) | code* | Approve | |
Review via email: mp+50174@code.launchpad.net |
Commit message
Description of the change
This is a pretty interesting branch. It needs a follow-up to fix some edge cases, but I'm already past the 800-line limit so I'll do it afterwards.
The idea behind this branch is that by adding a parameter to the Accept header when you ask for a JSON representation of an entry, you can get a JSON representation that includes the essential portions of an HTML representation.
GET /foo
Accept: application/
200 Ok
Content-Type: application/
{"foo" : "value", "bar" : "value",
"lp_html" : { "foo": "<b>value</b>", bar: "<a href=\"
}
That lp_html sub-dictionary contains HTML representations of any of the entry's fields that have an IFieldHTMLRenderer registered for them.
= Why? =
This is for the Launchpad website. Currently we get raw data from the JSON representation of the context, as stored in LP.client.
We need a single representation that contains both the raw data and the HTML representations. We need a *single* representation because when we make a PATCH request the server only sends back one document describing the new object. My squad discussed several alternatives and settled on extending the application/json media type with a parameter that includes a sub-dictionary of field HTML representations.
We will not be describing the application/
= How? =
1. The first big chunk of code to look at is parse_accept_
application/
The goal of parse_accept_
Unfortunately, the previous implementation of parse_accept_
2. I started passing in a 'media_type' argument to simplejson.dumps(). This is passed into the ResourceJSONEncoder constructor. This makes it all the way into EntryResource.
3. The rest of this diff is tests. It's mostly unit tests but I added a doctest to demonstrate the feature in a human-readable way.
I did a drive-by cleanup of a number of the tests in test_webservice (HTMLRepresenta
- 183. By Leonard Richardson
-
Minor cleanup in response to feedback.
Curtis Hovey (sinzui) wrote : | # |
Hi Leonard.
I am approving this branch because I think you have a good understanding of the issues here and the code is a good read. I have a few remarks/questions about lines I would have done differently because of issues I have seen while fixing bugs. The issues are trivial to fix if they exist and I am sure you can judge if you need to make them
=== modified file 'src/lazr/
--- src/lazr/
+++ src/lazr/
...
> @@ -163,6 +164,21 @@
> return str(value)
>
>
> +def _default_
> + """The default technique for rendering a value as an HTML snippet."""
> + return cgi.escape(
Is there any chance that the value could be a translatable message or a
structured string? If so, I think this should be
canonical.
...
> @@ -1359,7 +1336,8 @@
> SUPPORTED_
> HTTPResource.
> HTTPResource.
> - HTTPResource.
> + HTTPResource.
> + HTTPResource.
If this list was written as PEP 8 advises, there would be less to change:
...
> @@ -1438,9 +1416,21 @@
> data['web_link'] = absoluteURL(
> data['resource_
> unmarshalled_
> + html_renderings = {}
> for name, field in getFieldsInOrde
> - if media_type == self.JSON_TYPE:
> - repr_name, repr_value = self._unmarshal
> + if media_type.
> + repr_name, repr_value, html_renderer = (
> + self._field_
> + if media_type == self.JSON_
Can this line be wrapped? I cannot see it without scrolling left?
if (media_type == self.JSON_
> === modified file 'src/lazr/
> --- src/lazr/
> +++ src/lazr/
>
> @@ -90,3 +91,52 @@
>
> # For the sake of convenience, test_get_
> # and tag_request_
> +
> +
> +class TestParseAccept
> +
> + def test_single_
> + self.assertEqua
I believe assertEquals() is being deprecated. Our style guide says to use
assertEqual().
> === modified file 'src/lazr/
> --- src/lazr/
Preview Diff
1 | === modified file 'src/lazr/restful/NEWS.txt' |
2 | --- src/lazr/restful/NEWS.txt 2011-02-16 13:43:38 +0000 |
3 | +++ src/lazr/restful/NEWS.txt 2011-02-17 19:35:43 +0000 |
4 | @@ -2,6 +2,12 @@ |
5 | NEWS for lazr.restful |
6 | ===================== |
7 | |
8 | +0.17.0 (2011-02-17) |
9 | +=================== |
10 | + |
11 | +Added the ability to get a combined JSON/HTML representation of an |
12 | +entry that has custom HTML representations for some of its fields. |
13 | + |
14 | 0.16.1 (2011-02-16) |
15 | =================== |
16 | |
17 | |
18 | === modified file 'src/lazr/restful/_resource.py' |
19 | --- src/lazr/restful/_resource.py 2011-02-01 16:40:16 +0000 |
20 | +++ src/lazr/restful/_resource.py 2011-02-17 19:35:43 +0000 |
21 | @@ -118,6 +118,7 @@ |
22 | extract_write_portion, |
23 | get_current_browser_request, |
24 | get_current_web_service_request, |
25 | + parse_accept_style_header, |
26 | sorted_named_things, |
27 | ) |
28 | |
29 | @@ -163,6 +164,21 @@ |
30 | return str(value) |
31 | |
32 | |
33 | +def _default_html_renderer(value): |
34 | + """The default technique for rendering a value as an HTML snippet.""" |
35 | + return cgi.escape(unicode(value)) |
36 | + |
37 | + |
38 | +@component.adapter(Interface, IField, IWebServiceClientRequest) |
39 | +@implementer(IFieldHTMLRenderer) |
40 | +def render_field_to_html(object, field, request): |
41 | + """Turn a field's current value into an XHTML snippet. |
42 | + |
43 | + This is the default adapter for IFieldHTMLRenderer. |
44 | + """ |
45 | + return _default_html_renderer |
46 | + |
47 | + |
48 | def register_versioned_request_utility(interface, version): |
49 | """Registers a marker interface as a utility for version lookup. |
50 | |
51 | @@ -187,6 +203,10 @@ |
52 | instance, should implement IJSONPublishable. |
53 | """ |
54 | |
55 | + def __init__(self, *args, **kwargs): |
56 | + self.media_type = kwargs.pop('media_type', HTTPResource.JSON_TYPE) |
57 | + super(ResourceJSONEncoder, self).__init__(*args, **kwargs) |
58 | + |
59 | def default(self, obj): |
60 | """Convert the given object to a simple data structure.""" |
61 | if isinstance(obj, datetime) or isinstance(obj, date): |
62 | @@ -209,7 +229,7 @@ |
63 | if queryMultiAdapter((obj, request), IEntry): |
64 | obj = EntryResource(obj, request) |
65 | |
66 | - return IJSONPublishable(obj).toDataForJSON() |
67 | + return IJSONPublishable(obj).toDataForJSON(self.media_type) |
68 | |
69 | |
70 | class JSONItem: |
71 | @@ -220,7 +240,7 @@ |
72 | def __init__(self, context): |
73 | self.context = context |
74 | |
75 | - def toDataForJSON(self): |
76 | + def toDataForJSON(self, media_type): |
77 | """See `ISJONPublishable`""" |
78 | return str(self.context.title) |
79 | |
80 | @@ -246,6 +266,7 @@ |
81 | # Some interesting media types. |
82 | WADL_TYPE = 'application/vnd.sun.wadl+xml' |
83 | JSON_TYPE = 'application/json' |
84 | + JSON_PLUS_XHTML_TYPE = 'application/json;include=lp_html' |
85 | XHTML_TYPE = 'application/xhtml+xml' |
86 | |
87 | # This misspelling of the WADL media type was used for a while, |
88 | @@ -257,7 +278,7 @@ |
89 | WADL_TEMPLATE = LazrPageTemplateFile('templates/wadl-resource.pt') |
90 | |
91 | # All resources serve WADL and JSON representations. Only entry |
92 | - # resources serve XHTML representations. |
93 | + # resources serve XHTML representations and JSON+XHTML representations. |
94 | SUPPORTED_CONTENT_TYPES = [WADL_TYPE, DEPRECATED_WADL_TYPE, JSON_TYPE] |
95 | |
96 | def __init__(self, context, request): |
97 | @@ -472,7 +493,7 @@ |
98 | """Find which content types the client prefers to receive.""" |
99 | accept_header = (self.request.form.pop('ws.accept', None) |
100 | or self.request.get('HTTP_ACCEPT')) |
101 | - return self._parseAcceptStyleHeader(accept_header) |
102 | + return parse_accept_style_header(accept_header) |
103 | |
104 | def _parseETags(self, header_name): |
105 | """Extract a list of ETags from a header and parse the list. |
106 | @@ -534,52 +555,6 @@ |
107 | params[name] = value |
108 | return (disposition, params) |
109 | |
110 | - def _parseAcceptStyleHeader(self, value): |
111 | - """Parse an HTTP header from the Accept-* family. |
112 | - |
113 | - These headers contain a list of possible values, each with an |
114 | - optional priority. |
115 | - |
116 | - This code is modified from Zope's |
117 | - BrowserLanguages#getPreferredLanguages. |
118 | - |
119 | - :return: All values, in descending order of priority. |
120 | - """ |
121 | - if value is None: |
122 | - return [] |
123 | - |
124 | - values = value.split(',') |
125 | - # In the original getPreferredLanguages there was some language |
126 | - # code normalization here, which I removed. |
127 | - values = [v for v in values if v != ""] |
128 | - |
129 | - accepts = [] |
130 | - for index, value in enumerate(values): |
131 | - l = value.split(';', 2) |
132 | - |
133 | - # If not supplied, quality defaults to 1... |
134 | - quality = 1.0 |
135 | - |
136 | - if len(l) == 2: |
137 | - q = l[1] |
138 | - if q.startswith('q='): |
139 | - q = q.split('=', 2)[1] |
140 | - quality = float(q) |
141 | - |
142 | - if quality == 1.0: |
143 | - # ... but we use 1.9 - 0.001 * position to |
144 | - # keep the ordering between all items with |
145 | - # 1.0 quality, which may include items with no quality |
146 | - # defined, and items with quality defined as 1. |
147 | - quality = 1.9 - (0.001 * index) |
148 | - |
149 | - accepts.append((quality, l[0].strip())) |
150 | - |
151 | - accepts = [acc for acc in accepts if acc[0] > 0] |
152 | - accepts.sort() |
153 | - accepts.reverse() |
154 | - return [value for quality, value in accepts] |
155 | - |
156 | |
157 | class WebServiceBatchNavigator(BatchNavigator): |
158 | """A batch navigator that speaks to web service clients. |
159 | @@ -835,13 +810,13 @@ |
160 | self._unmarshalled_field_cache[field_name] = unmarshalled |
161 | return unmarshalled |
162 | |
163 | - def unmarshallFieldToHTML(self, field_name, field): |
164 | - """See what a field would look like in an HTML representation. |
165 | - |
166 | - This is usually similar to the value of _unmarshallField(), |
167 | - but it might contain some custom HTML weirdness. |
168 | - |
169 | - :return: a 2-tuple (representation_name, representation_value). |
170 | + def _field_with_html_renderer(self, field_name, field): |
171 | + """Find an IFieldHTMLRenderer for the given field. |
172 | + |
173 | + :return: A 3-tuple (name, value, renderer). 'name' and 'value' |
174 | + are the unmarshalled field name and JSON-ready |
175 | + value. 'renderer' is an object capable of turning the |
176 | + JSON-ready value into an HTML value. |
177 | """ |
178 | name, value = self._unmarshallField(field_name, field) |
179 | try: |
180 | @@ -856,21 +831,21 @@ |
181 | renderer = getMultiAdapter( |
182 | (self.entry.context, field, self.request), |
183 | IFieldHTMLRenderer) |
184 | + return name, value, renderer |
185 | + |
186 | + def unmarshallFieldToHTML(self, field_name, field): |
187 | + """See what a field would look like in an HTML representation. |
188 | + |
189 | + This is usually similar to the value of _unmarshallField(), |
190 | + but it might contain some custom HTML weirdness. |
191 | + |
192 | + :return: a 2-tuple (representation_name, representation_value). |
193 | + """ |
194 | + name, value, renderer = self._field_with_html_renderer( |
195 | + field_name, field) |
196 | return name, renderer(value) |
197 | |
198 | |
199 | -@component.adapter(Interface, IField, IWebServiceClientRequest) |
200 | -@implementer(IFieldHTMLRenderer) |
201 | -def render_field_to_html(object, field, request): |
202 | - """Turn a field's current value into an XHTML snippet. |
203 | - |
204 | - This is the default adapter for IFieldHTMLRenderer. |
205 | - """ |
206 | - def unmarshall(value): |
207 | - return cgi.escape(unicode(value)) |
208 | - return unmarshall |
209 | - |
210 | - |
211 | class ReadOnlyResource(HTTPResource): |
212 | """A resource that serves a string in response to GET.""" |
213 | |
214 | @@ -1165,8 +1140,9 @@ |
215 | # fields that don't correspond to some field in the |
216 | # schema. They're all errors. |
217 | for invalid_field in changeset.keys(): |
218 | - errors.append(u"%s: You tried to modify a nonexistent " |
219 | - "attribute." % invalid_field) |
220 | + if not invalid_field.endswith('_html'): |
221 | + errors.append(u"%s: You tried to modify a nonexistent " |
222 | + "attribute." % invalid_field) |
223 | |
224 | # If there were errors, display them and send a status of 400. |
225 | if len(errors) > 0: |
226 | @@ -1239,6 +1215,7 @@ |
227 | """ |
228 | pass |
229 | |
230 | + |
231 | class EntryFieldResource(FieldUnmarshallerMixin, EntryManipulatingResource): |
232 | """An individual field of an entry.""" |
233 | implements(IEntryFieldResource, IJSONPublishable) |
234 | @@ -1359,7 +1336,8 @@ |
235 | SUPPORTED_CONTENT_TYPES = [HTTPResource.WADL_TYPE, |
236 | HTTPResource.DEPRECATED_WADL_TYPE, |
237 | HTTPResource.XHTML_TYPE, |
238 | - HTTPResource.JSON_TYPE] |
239 | + HTTPResource.JSON_TYPE, |
240 | + HTTPResource.JSON_PLUS_XHTML_TYPE] |
241 | |
242 | def __init__(self, context, request): |
243 | """Associate this resource with a specific object and request.""" |
244 | @@ -1416,9 +1394,9 @@ |
245 | return existing_write_portion in incoming_write_portions |
246 | |
247 | |
248 | - def toDataForJSON(self): |
249 | + def toDataForJSON(self, media_type=None): |
250 | """Turn the object into a simple data structure.""" |
251 | - return self.toDataStructure(self.JSON_TYPE) |
252 | + return self.toDataStructure(media_type or self.JSON_TYPE) |
253 | |
254 | def toDataStructure(self, media_type): |
255 | """Turn the object into a simple data structure. |
256 | @@ -1438,9 +1416,21 @@ |
257 | data['web_link'] = absoluteURL(self.context, browser_request) |
258 | data['resource_type_link'] = self.type_url |
259 | unmarshalled_field_values = {} |
260 | + html_renderings = {} |
261 | for name, field in getFieldsInOrder(self.entry.schema): |
262 | - if media_type == self.JSON_TYPE: |
263 | - repr_name, repr_value = self._unmarshallField(name, field) |
264 | + if media_type.startswith(self.JSON_TYPE): |
265 | + repr_name, repr_value, html_renderer = ( |
266 | + self._field_with_html_renderer(name, field)) |
267 | + if media_type == self.JSON_PLUS_XHTML_TYPE and html_renderer != _default_html_renderer: |
268 | + # This field has an unusual HTML renderer. |
269 | + if repr_value == self.REDACTED_VALUE: |
270 | + # The data is redacted, so the HTML |
271 | + # representation of that data should also |
272 | + # be redacted. |
273 | + html_value = self.REDACTED_VALUE |
274 | + else: |
275 | + html_value = html_renderer(repr_value) |
276 | + html_renderings[repr_name] = html_value |
277 | elif media_type == self.XHTML_TYPE: |
278 | repr_name, repr_value = self.unmarshallFieldToHTML( |
279 | name, field) |
280 | @@ -1451,6 +1441,8 @@ |
281 | data[repr_name] = repr_value |
282 | unmarshalled_field_values[name] = repr_value |
283 | |
284 | + if len(html_renderings) > 0: |
285 | + data['lp_html'] = html_renderings |
286 | etag = self.getETag(media_type, unmarshalled_field_values) |
287 | data['http_etag'] = etag |
288 | return data |
289 | @@ -1647,19 +1639,20 @@ |
290 | |
291 | if media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]: |
292 | return self.toWADL().encode("utf-8") |
293 | - elif media_type == self.JSON_TYPE: |
294 | + elif media_type in (self.JSON_TYPE, self.JSON_PLUS_XHTML_TYPE): |
295 | cache = self._representation_cache |
296 | if cache is None: |
297 | representation = None |
298 | else: |
299 | representation = cache.get( |
300 | - self.context, self.JSON_TYPE, self.request.version) |
301 | + self.context, media_type, self.request.version) |
302 | |
303 | redacted_fields = self.redacted_fields |
304 | if representation is None: |
305 | # Either there is no active cache, or the representation |
306 | # wasn't in the cache. |
307 | - representation = simplejson.dumps(self, cls=ResourceJSONEncoder) |
308 | + representation = simplejson.dumps( |
309 | + self, cls=ResourceJSONEncoder, media_type=media_type) |
310 | # If there's an active cache, and this representation |
311 | # doesn't contain any redactions, store it in the |
312 | # cache. |
313 | @@ -1960,7 +1953,7 @@ |
314 | namespace['collections'] = sorted_named_things(collection_classes) |
315 | return self.WADL_TEMPLATE.pt_render(namespace) |
316 | |
317 | - def toDataForJSON(self): |
318 | + def toDataForJSON(self, media_type): |
319 | """Return a map of links to top-level collection resources. |
320 | |
321 | A top-level resource is one that adapts a utility. Currently |
322 | |
323 | === modified file 'src/lazr/restful/example/base/tests/field.txt' |
324 | --- src/lazr/restful/example/base/tests/field.txt 2011-02-02 17:48:38 +0000 |
325 | +++ src/lazr/restful/example/base/tests/field.txt 2011-02-17 19:35:43 +0000 |
326 | @@ -274,13 +274,12 @@ |
327 | ... IFieldHTMLRenderer, IWebServiceClientRequest) |
328 | >>> from lazr.restful.example.base.interfaces import ICookbook |
329 | |
330 | + >>> from lazr.restful.testing.webservice import simple_renderer |
331 | >>> @component.adapter(ICookbook, ITextLine, IWebServiceClientRequest) |
332 | ... @implementer(IFieldHTMLRenderer) |
333 | ... def dummy_renderer(context, field, request): |
334 | ... """Bold the original string and add a snowman.""" |
335 | - ... def render(value): |
336 | - ... return u"\N{SNOWMAN} <b>%s</b>" % value |
337 | - ... return render |
338 | + ... return simple_renderer |
339 | |
340 | >>> print webservice.get(cookbook_url +'/name', 'application/xhtml+xml') |
341 | HTTP/1.1 200 Ok |
342 | @@ -328,6 +327,40 @@ |
343 | ... |
344 | 1995-01-01 |
345 | |
346 | +Combined JSON/HTML representations |
347 | +---------------------------------- |
348 | + |
349 | +You can get a combined JSON/HTML representation of an entry by setting |
350 | +the "include=lp_html" parameter on the application/json media type. |
351 | + |
352 | + >>> json = webservice.get( |
353 | + ... cookbook_url, 'application/json;include=lp_html').jsonBody() |
354 | + |
355 | +The cookbook's description is a normal JSON representation... |
356 | + |
357 | + >>> print json['description'] |
358 | + Description |
359 | + |
360 | +...but the JSON dictionary will include a 'lp_html' sub-dictionary... |
361 | + |
362 | + >>> html = json['lp_html'] |
363 | + |
364 | +...which includes HTML representations of the fields with HTML |
365 | +representations: |
366 | + |
367 | + >>> sorted(html.keys()) |
368 | + [u'description', u'name'] |
369 | + |
370 | + >>> from lazr.restful.testing.helpers import encode_unicode |
371 | + >>> print encode_unicode(html['description']) |
372 | + \u2603 <b>Description</b> |
373 | + |
374 | + >>> print encode_unicode(html['name']) |
375 | + \u2603 <b>The Joy of Cooking</b> |
376 | + |
377 | +Cleanup |
378 | +------- |
379 | + |
380 | Before we continue, here's some cleanup code to remove the custom |
381 | renderer we just defined. |
382 | |
383 | |
384 | === modified file 'src/lazr/restful/interfaces/_rest.py' |
385 | --- src/lazr/restful/interfaces/_rest.py 2011-01-20 23:54:37 +0000 |
386 | +++ src/lazr/restful/interfaces/_rest.py 2011-02-17 19:35:43 +0000 |
387 | @@ -91,11 +91,15 @@ |
388 | class IJSONPublishable(Interface): |
389 | """An object that can be published as a JSON data structure.""" |
390 | |
391 | - def toDataForJSON(): |
392 | + def toDataForJSON(media_type): |
393 | """Return a representation that can be turned into JSON. |
394 | |
395 | The representation must consist entirely of simple data |
396 | structures and IJSONPublishable objects. |
397 | + |
398 | + :param media_type: The media type that the data will be |
399 | + converted to. This will be application/json, obviously, but it |
400 | + may include parameters. |
401 | """ |
402 | |
403 | class IServiceRootResource(IHTTPResource): |
404 | |
405 | === modified file 'src/lazr/restful/testing/helpers.py' |
406 | --- src/lazr/restful/testing/helpers.py 2011-02-02 18:00:38 +0000 |
407 | +++ src/lazr/restful/testing/helpers.py 2011-02-17 19:35:43 +0000 |
408 | @@ -48,7 +48,18 @@ |
409 | :param response: an httplib HTTPResponse object. |
410 | """ |
411 | response_unicode = str(response).decode("utf-8") |
412 | - return response_unicode.encode("ascii", "backslashreplace") |
413 | + return encode_unicode(response_unicode) |
414 | + |
415 | + |
416 | +def encode_unicode(unicode_string): |
417 | + """Encode a Unicode string so it can be used in tests. |
418 | + |
419 | + The resulting string will (eg.) contain the six characters |
420 | + "\u2603" instead of the single Unicode character SNOWMAN. |
421 | + |
422 | + :param unicode_string: A Unicode string. |
423 | + """ |
424 | + return unicode_string.encode("ascii", "backslashreplace") |
425 | |
426 | |
427 | class TestWebServiceConfiguration: |
428 | |
429 | === modified file 'src/lazr/restful/testing/webservice.py' |
430 | --- src/lazr/restful/testing/webservice.py 2011-02-02 13:31:21 +0000 |
431 | +++ src/lazr/restful/testing/webservice.py 2011-02-17 19:35:43 +0000 |
432 | @@ -14,6 +14,7 @@ |
433 | 'IGenericEntry', |
434 | 'IGenericCollection', |
435 | 'pprint_entry', |
436 | + 'simple_renderer', |
437 | 'WebServiceTestCase', |
438 | 'WebServiceTestConfiguration' |
439 | 'WebServiceTestPublication', |
440 | @@ -565,3 +566,14 @@ |
441 | adapts(DummyRootResource, IWebServiceClientRequest) |
442 | |
443 | URL = 'http://dummyurl' |
444 | + |
445 | + |
446 | +def simple_renderer(value): |
447 | + """Bold the original string and add a snowman. |
448 | + |
449 | + To use this function, define an IHTMLFieldRenderer that returns it. |
450 | + |
451 | + This is a good HTML field renderer to use in tests, because it |
452 | + tests Unicode values and embedded HTML. |
453 | + """ |
454 | + return u"\N{SNOWMAN} <b>%s</b>" % value |
455 | |
456 | === modified file 'src/lazr/restful/tests/test_utils.py' |
457 | --- src/lazr/restful/tests/test_utils.py 2010-09-28 12:14:12 +0000 |
458 | +++ src/lazr/restful/tests/test_utils.py 2011-02-17 19:35:43 +0000 |
459 | @@ -15,6 +15,7 @@ |
460 | extract_write_portion, |
461 | get_current_browser_request, |
462 | is_total_size_link_active, |
463 | + parse_accept_style_header, |
464 | sorted_named_things, |
465 | ) |
466 | |
467 | @@ -90,3 +91,52 @@ |
468 | |
469 | # For the sake of convenience, test_get_current_web_service_request() |
470 | # and tag_request_with_version_name() are tested in test_webservice.py. |
471 | + |
472 | + |
473 | +class TestParseAcceptStyleHeader(unittest.TestCase): |
474 | + |
475 | + def test_single_value(self): |
476 | + self.assertEquals(parse_accept_style_header("foo"), ["foo"]) |
477 | + |
478 | + def test_multiple_unodered_values(self): |
479 | + self.assertEquals( |
480 | + parse_accept_style_header("foo, bar"), |
481 | + ["foo", "bar"]) |
482 | + |
483 | + self.assertEquals( |
484 | + parse_accept_style_header("foo, bar,baz"), |
485 | + ["foo", "bar", "baz"]) |
486 | + |
487 | + def test_highest_quality_parameter_wins(self): |
488 | + self.assertEquals( |
489 | + parse_accept_style_header("foo;q=0.001, bar;q=0.05, baz;q=0.1"), |
490 | + ["baz", "bar", "foo"]) |
491 | + |
492 | + def test_quality_zero_is_omitted(self): |
493 | + self.assertEquals( |
494 | + parse_accept_style_header("foo;q=0, bar;q=0.5"), ["bar"]) |
495 | + |
496 | + def test_no_quality_parameter_is_implicit_one_point_zero(self): |
497 | + self.assertEquals( |
498 | + parse_accept_style_header("foo;q=0.5, bar"), |
499 | + ["bar", "foo"]) |
500 | + |
501 | + def test_standalone_parameter_is_untouched(self): |
502 | + self.assertEquals( |
503 | + parse_accept_style_header("foo;a=0.5"), |
504 | + ["foo;a=0.5"]) |
505 | + |
506 | + def test_quality_parameter_is_removed_next_parameter_is_untouched(self): |
507 | + self.assertEquals( |
508 | + parse_accept_style_header("foo;a=bar;q=0.5"), |
509 | + ["foo;a=bar"]) |
510 | + |
511 | + def test_quality_parameter_is_removed_earlier_parameter_is_untouched(self): |
512 | + self.assertEquals( |
513 | + parse_accept_style_header("foo;q=0.5;a=bar"), |
514 | + ["foo;a=bar"]) |
515 | + |
516 | + def test_quality_parameter_is_removed_surrounding_parameters_are_untouched(self): |
517 | + self.assertEquals( |
518 | + parse_accept_style_header("foo;a=bar;q=0.5;b=baz"), |
519 | + ["foo;a=bar;b=baz"]) |
520 | |
521 | === modified file 'src/lazr/restful/tests/test_webservice.py' |
522 | --- src/lazr/restful/tests/test_webservice.py 2011-02-02 13:25:47 +0000 |
523 | +++ src/lazr/restful/tests/test_webservice.py 2011-02-17 19:35:43 +0000 |
524 | @@ -19,6 +19,7 @@ |
525 | from zope.interface.interface import InterfaceClass |
526 | from zope.publisher.browser import TestRequest |
527 | from zope.schema import Choice, Date, Datetime, TextLine |
528 | +from zope.schema.interfaces import ITextLine |
529 | from zope.security.management import ( |
530 | endInteraction, |
531 | newInteraction, |
532 | @@ -37,6 +38,7 @@ |
533 | from lazr.restful.interfaces import ( |
534 | ICollection, |
535 | IEntry, |
536 | + IFieldHTMLRenderer, |
537 | IResourceGETOperation, |
538 | IServiceRootResource, |
539 | IWebBrowserOriginatingRequest, |
540 | @@ -56,6 +58,7 @@ |
541 | DummyAbsoluteURL, |
542 | IGenericCollection, |
543 | IGenericEntry, |
544 | + simple_renderer, |
545 | WebServiceTestCase, |
546 | ) |
547 | from lazr.restful.testing.tales import test_tales |
548 | @@ -130,6 +133,8 @@ |
549 | |
550 | WADL_NS = "{http://research.sun.com/wadl/2006/10}" |
551 | |
552 | + default_media_type = "application/json" |
553 | + |
554 | class DummyWebsiteRequest: |
555 | """A request to the website, as opposed to the web service.""" |
556 | implements(IWebBrowserOriginatingRequest) |
557 | @@ -139,9 +144,10 @@ |
558 | URL = 'http://www.website.url/' |
559 | |
560 | @contextmanager |
561 | - def request(self): |
562 | + def request(self, media_type=None): |
563 | + media_type = media_type or self.default_media_type |
564 | request = getUtility(IWebServiceConfiguration).createRequest( |
565 | - StringIO(""), {}) |
566 | + StringIO(""), {'HTTP_ACCEPT' : media_type}) |
567 | newInteraction(request) |
568 | yield request |
569 | endInteraction() |
570 | @@ -153,16 +159,40 @@ |
571 | return request.publication.application.toWADL().encode('utf-8') |
572 | |
573 | @contextmanager |
574 | - def entry_resource(self, entry_interface, entry_implementation): |
575 | + def entry_resource(self, entry_interface, entry_implementation, |
576 | + *implementation_args): |
577 | """Create a request to an entry resource, and yield the resource.""" |
578 | entry_class = get_resource_factory(entry_interface, IEntry) |
579 | - data_object = entry_implementation("") |
580 | + data_object = entry_implementation(*implementation_args) |
581 | |
582 | with self.request() as request: |
583 | entry = entry_class(data_object, request) |
584 | resource = EntryResource(data_object, request) |
585 | yield resource |
586 | |
587 | + @contextmanager |
588 | + def entry_field_resource(self, entry_interface, entry_implementation, |
589 | + field_name, *implementation_args): |
590 | + entry_class = get_resource_factory(entry_interface, IEntry) |
591 | + data_object = entry_implementation(*implementation_args) |
592 | + with self.request() as request: |
593 | + entry = entry_class(data_object, request) |
594 | + field = entry.schema.get(field_name) |
595 | + entry_field = EntryField(entry, field, field_name) |
596 | + resource = EntryFieldResource(entry_field, request) |
597 | + yield resource |
598 | + |
599 | + def register_html_field_renderer(self, entry_interface, field_interface, |
600 | + render_function, name=''): |
601 | + """Register an HTML representation for a field or class of field.""" |
602 | + def renderer(context, field, request): |
603 | + return render_function |
604 | + |
605 | + getGlobalSiteManager().registerAdapter( |
606 | + renderer, |
607 | + (entry_interface, field_interface, IWebServiceClientRequest), |
608 | + IFieldHTMLRenderer, name=name) |
609 | + |
610 | def _register_url_adapter(self, entry_interface): |
611 | """Register an IAbsoluteURL implementation for an interface.""" |
612 | getGlobalSiteManager().registerAdapter( |
613 | @@ -204,7 +234,22 @@ |
614 | self.a_field = value |
615 | |
616 | |
617 | -class TestEntryRead(EntryTestCase): |
618 | +class IHasTwoFields(Interface): |
619 | + """An entry with two fields.""" |
620 | + export_as_webservice_entry() |
621 | + a_field = exported(TextLine(title=u"A field.")) |
622 | + another_field = exported(TextLine(title=u"Another field.")) |
623 | + |
624 | + |
625 | +class HasTwoFields: |
626 | + """An implementation of IHasTwoFields.""" |
627 | + implements(IHasTwoFields) |
628 | + def __init__(self, value1, value2): |
629 | + self.a_field = value1 |
630 | + self.another_field = value2 |
631 | + |
632 | + |
633 | +class TestEntryWebLink(EntryTestCase): |
634 | |
635 | testmodule_objects = [HasOneField, IHasOneField] |
636 | |
637 | @@ -221,7 +266,7 @@ |
638 | |
639 | # Now a representation of IHasOneField includes a |
640 | # 'web_link'. |
641 | - with self.entry_resource(IHasOneField, HasOneField) as resource: |
642 | + with self.entry_resource(IHasOneField, HasOneField, "") as resource: |
643 | representation = resource.toDataForJSON() |
644 | self.assertEquals(representation['self_link'], DummyAbsoluteURL.URL) |
645 | self.assertEquals( |
646 | @@ -253,7 +298,7 @@ |
647 | # entry representations. |
648 | self._register_url_adapter(IHasOneField) |
649 | |
650 | - with self.entry_resource(IHasOneField, HasOneField) as resource: |
651 | + with self.entry_resource(IHasOneField, HasOneField, "") as resource: |
652 | representation = resource.toDataForJSON() |
653 | self.assertEquals( |
654 | representation['self_link'], DummyAbsoluteURL.URL) |
655 | @@ -288,7 +333,8 @@ |
656 | def test_entry_omits_web_link_when_suppressed(self): |
657 | self._register_website_url_space(IHasNoWebLink) |
658 | |
659 | - with self.entry_resource(IHasNoWebLink, HasNoWebLink) as resource: |
660 | + with self.entry_resource(IHasNoWebLink, HasNoWebLink, "") as ( |
661 | + resource): |
662 | representation = resource.toDataForJSON() |
663 | self.assertEquals( |
664 | representation['self_link'], DummyAbsoluteURL.URL) |
665 | @@ -327,11 +373,10 @@ |
666 | |
667 | testmodule_objects = [IHasOneField, HasOneField] |
668 | |
669 | - |
670 | def test_applyChanges_rejects_nonexistent_web_link(self): |
671 | # If web_link is not published, applyChanges rejects a request |
672 | # that references it. |
673 | - with self.entry_resource(IHasOneField, HasOneField) as resource: |
674 | + with self.entry_resource(IHasOneField, HasOneField, "") as resource: |
675 | errors = resource.applyChanges({'web_link': u'some_value'}) |
676 | self.assertEquals( |
677 | errors, |
678 | @@ -341,7 +386,7 @@ |
679 | """applyChanges rejects an attempt to change web_link .""" |
680 | self._register_website_url_space(IHasOneField) |
681 | |
682 | - with self.entry_resource(IHasOneField, HasOneField) as resource: |
683 | + with self.entry_resource(IHasOneField, HasOneField, "") as resource: |
684 | errors = resource.applyChanges({'web_link': u'some_value'}) |
685 | self.assertEquals( |
686 | errors, |
687 | @@ -352,7 +397,7 @@ |
688 | # value isn't actually being changed. |
689 | self._register_website_url_space(IHasOneField) |
690 | |
691 | - with self.entry_resource(IHasOneField, HasOneField) as resource: |
692 | + with self.entry_resource(IHasOneField, HasOneField, "") as resource: |
693 | existing_web_link = resource.toDataForJSON()['web_link'] |
694 | representation = simplejson.loads(resource.applyChanges( |
695 | {'web_link': existing_web_link})) |
696 | @@ -372,8 +417,8 @@ |
697 | expose the right interface, it will raise an exception. |
698 | """ |
699 | self._register_url_adapter(IHasRestrictedField) |
700 | - with self.entry_resource(IHasRestrictedField, HasRestrictedField) as ( |
701 | - resource): |
702 | + with self.entry_resource( |
703 | + IHasRestrictedField, HasRestrictedField, "") as resource: |
704 | entry = resource.entry |
705 | entry.schema['a_field'].restrict_to_interface = IHasRestrictedField |
706 | self.assertEquals(entry.a_field, '') |
707 | @@ -390,36 +435,83 @@ |
708 | self.assertEquals(resource.entry.a_field, 'a_value') |
709 | |
710 | |
711 | -class HTMLRepresentationTest(WebServiceTestCase): |
712 | +class HTMLRepresentationTest(EntryTestCase): |
713 | |
714 | - testmodule_objects = [HasRestrictedField, IHasRestrictedField] |
715 | + testmodule_objects = [HasOneField, IHasOneField] |
716 | + default_media_type = "application/xhtml+xml" |
717 | |
718 | def setUp(self): |
719 | super(HTMLRepresentationTest, self).setUp() |
720 | - getGlobalSiteManager().registerAdapter( |
721 | - DummyAbsoluteURL, [IHasRestrictedField, IWebServiceClientRequest], |
722 | - IAbsoluteURL) |
723 | + self._register_url_adapter(IHasOneField) |
724 | self.unicode_message = u"Hello from a \N{SNOWMAN}" |
725 | self.utf8_message = self.unicode_message.encode('utf-8') |
726 | - self.data_object = HasRestrictedField(self.unicode_message) |
727 | |
728 | def test_entry_html_representation_is_utf8(self): |
729 | - request = getUtility(IWebServiceConfiguration).createRequest( |
730 | - StringIO(""), {'HTTP_ACCEPT' : 'application/xhtml+xml'}) |
731 | - resource = EntryResource(self.data_object, request) |
732 | - html = resource.do_GET() |
733 | - self.assertTrue(self.utf8_message in html) |
734 | + with self.entry_resource( |
735 | + IHasOneField, HasOneField, self.unicode_message) as resource: |
736 | + html = resource.do_GET() |
737 | + self.assertTrue(self.utf8_message in html) |
738 | |
739 | def test_field_html_representation_is_utf8(self): |
740 | - request = getUtility(IWebServiceConfiguration).createRequest( |
741 | - StringIO(""), {'HTTP_ACCEPT' : 'application/xhtml+xml'}) |
742 | - entry_class = get_resource_factory(IHasRestrictedField, IEntry) |
743 | - entry = entry_class(self.data_object, request) |
744 | - field = entry.schema.get("a_field") |
745 | - entry_field = EntryField(entry, field, "a_field") |
746 | - resource = EntryFieldResource(entry_field, request) |
747 | - html = resource.do_GET() |
748 | - self.assertTrue(html == self.utf8_message) |
749 | + with self.entry_field_resource( |
750 | + IHasOneField, HasOneField, "a_field", |
751 | + self.unicode_message) as resource: |
752 | + html = resource.do_GET() |
753 | + self.assertTrue(html == self.utf8_message) |
754 | + |
755 | + |
756 | +class JSONPlusHTMLRepresentationTest(EntryTestCase): |
757 | + |
758 | + testmodule_objects = [HasTwoFields, IHasTwoFields] |
759 | + |
760 | + def setUp(self): |
761 | + super(JSONPlusHTMLRepresentationTest, self).setUp() |
762 | + self.default_media_type = "application/json;include=lp_html" |
763 | + self._register_url_adapter(IHasTwoFields) |
764 | + |
765 | + def register_html_field_renderer(self, name=''): |
766 | + """Simplify the register_html_field_renderer call.""" |
767 | + super(JSONPlusHTMLRepresentationTest, |
768 | + self).register_html_field_renderer( |
769 | + IHasTwoFields, ITextLine, simple_renderer, name) |
770 | + |
771 | + @contextmanager |
772 | + def resource(self, value_1="value 1", value_2 = "value 2"): |
773 | + """Simplify the entry_resource call.""" |
774 | + with self.entry_resource( |
775 | + IHasTwoFields, HasTwoFields, value_1, value_2) as resource: |
776 | + yield resource |
777 | + |
778 | + def test_normal_json_representation_omits_lp_html(self): |
779 | + self.default_media_type = "application/json" |
780 | + self.register_html_field_renderer() |
781 | + with self.resource() as resource: |
782 | + json = simplejson.loads(resource.do_GET()) |
783 | + self.assertFalse('lp_html' in json) |
784 | + |
785 | + def test_entry_with_no_html_renderers_omits_lp_html(self): |
786 | + with self.resource() as resource: |
787 | + json = simplejson.loads(resource.do_GET()) |
788 | + self.assertFalse('lp_html' in json) |
789 | + |
790 | + def test_field_specific_html_renderer_shows_up_in_lp_html(self): |
791 | + self.register_html_field_renderer("a_field") |
792 | + with self.resource() as resource: |
793 | + json = simplejson.loads(resource.do_GET()) |
794 | + html = json['lp_html'] |
795 | + self.assertEquals( |
796 | + html['a_field'], simple_renderer(resource.entry.a_field)) |
797 | + |
798 | + def test_html_renderer_for_class_renders_all_fields_of_that_class(self): |
799 | + self.register_html_field_renderer() |
800 | + with self.resource() as resource: |
801 | + json = simplejson.loads(resource.do_GET()) |
802 | + html = json['lp_html'] |
803 | + self.assertEquals( |
804 | + html['a_field'], simple_renderer(resource.entry.a_field)) |
805 | + self.assertEquals( |
806 | + html['another_field'], |
807 | + simple_renderer(resource.entry.another_field)) |
808 | |
809 | |
810 | class UnicodeChoice(EnumeratedType): |
811 | @@ -444,37 +536,30 @@ |
812 | self.a_field = value |
813 | |
814 | |
815 | -class UnicodeErrorTestCase(WebServiceTestCase): |
816 | +class UnicodeErrorTestCase(EntryTestCase): |
817 | """Test that Unicode error strings are properly passed through.""" |
818 | |
819 | testmodule_objects = [CanBeSetToUnicodeValue, ICanBeSetToUnicodeValue] |
820 | |
821 | def setUp(self): |
822 | super(UnicodeErrorTestCase, self).setUp() |
823 | - getGlobalSiteManager().registerAdapter( |
824 | - DummyAbsoluteURL, |
825 | - [ICanBeSetToUnicodeValue, IWebServiceClientRequest], IAbsoluteURL) |
826 | + self._register_url_adapter(ICanBeSetToUnicodeValue) |
827 | |
828 | def test_unicode_error(self): |
829 | - entry_class = get_resource_factory(ICanBeSetToUnicodeValue, IEntry) |
830 | - request = getUtility(IWebServiceConfiguration).createRequest( |
831 | - StringIO(""), {}) |
832 | - |
833 | - data_object = CanBeSetToUnicodeValue("") |
834 | - entry = entry_class(data_object, request) |
835 | - resource = EntryResource(data_object, request) |
836 | - |
837 | - # This will raise an exception, which will cause the request |
838 | - # to fail with a 400 error code. |
839 | - error = resource.applyChanges({'a_field': u'No such value'}) |
840 | - self.assertEqual(request.response.getStatus(), 400) |
841 | - |
842 | - # The error message is a Unicode string which mentions both |
843 | - # the ASCII value and the Unicode value, |
844 | - expected_error = ( |
845 | - u'a_field: Invalid value "No such value". Acceptable values ' |
846 | - u'are: Ascii, Uni\u00e7ode') |
847 | - self.assertEquals(error, expected_error) |
848 | + with self.entry_resource( |
849 | + ICanBeSetToUnicodeValue, CanBeSetToUnicodeValue, "") as resource: |
850 | + |
851 | + # This will raise an exception, which will cause the request |
852 | + # to fail with a 400 error code. |
853 | + error = resource.applyChanges({'a_field': u'No such value'}) |
854 | + self.assertEqual(resource.request.response.getStatus(), 400) |
855 | + |
856 | + # The error message is a Unicode string which mentions both |
857 | + # the ASCII value and the Unicode value, |
858 | + expected_error = ( |
859 | + u'a_field: Invalid value "No such value". Acceptable values ' |
860 | + u'are: Ascii, Uni\u00e7ode') |
861 | + self.assertEquals(error, expected_error) |
862 | |
863 | |
864 | class WadlAPITestCase(WebServiceTestCase): |
865 | |
866 | === modified file 'src/lazr/restful/utils.py' |
867 | --- src/lazr/restful/utils.py 2010-09-28 12:14:12 +0000 |
868 | +++ src/lazr/restful/utils.py 2011-02-17 19:35:43 +0000 |
869 | @@ -9,6 +9,7 @@ |
870 | 'get_current_web_service_request', |
871 | 'implement_from_dict', |
872 | 'make_identifier_safe', |
873 | + 'parse_accept_style_header', |
874 | 'safe_js_escape', |
875 | 'safe_hasattr', |
876 | 'smartquote', |
877 | @@ -55,6 +56,70 @@ |
878 | return versions.index(total_size_link_version) <= versions.index(version) |
879 | |
880 | |
881 | +def parse_accept_style_header(value): |
882 | + """Parse an HTTP header from the Accept-* family. |
883 | + |
884 | + These headers contain a list of possible values, each with an |
885 | + optional priority. |
886 | + |
887 | + This code is modified from Zope's |
888 | + BrowserLanguages#getPreferredLanguages. |
889 | + |
890 | + If a value includes parameters other than 'q', they will be preserved. |
891 | + |
892 | + :return: All values, in descending order of priority. |
893 | + """ |
894 | + if value is None: |
895 | + return [] |
896 | + |
897 | + values = value.split(',') |
898 | + values = [v.strip() for v in values if v != ""] |
899 | + |
900 | + accepts = [] |
901 | + for index, value in enumerate(values): |
902 | + value_and_parameters = value.split(';') |
903 | + |
904 | + # If not supplied, quality defaults to 1. |
905 | + quality = 1.0 |
906 | + |
907 | + if len(value_and_parameters) > 1: |
908 | + # There's at least one parameter, possibly including |
909 | + # a quality parameter. |
910 | + quality_parameter = None |
911 | + bare_value = value_and_parameters[0] |
912 | + parameters = value_and_parameters[1:] |
913 | + for parameter_index, parameter in enumerate(parameters): |
914 | + if parameter.startswith('q='): |
915 | + # We found the quality parameter |
916 | + quality_parameter = parameter |
917 | + q = quality_parameter.split('=', 2)[1] |
918 | + quality = float(q) |
919 | + |
920 | + if quality_parameter is not None: |
921 | + # Remove the quality parameter from the list of |
922 | + # parameters, and re-create the header value without |
923 | + # it. |
924 | + parameters.remove(quality_parameter) |
925 | + if len(parameters) > 0: |
926 | + value = ';'.join([bare_value] + parameters) |
927 | + else: |
928 | + value = bare_value |
929 | + |
930 | + if quality == 1.0: |
931 | + # ... but we use 1.9 - 0.001 * position to |
932 | + # keep the ordering between all items with |
933 | + # 1.0 quality, which may include items with no quality |
934 | + # defined, and items with quality defined as 1. |
935 | + quality = 1.9 - (0.001 * index) |
936 | + |
937 | + accepts.append((quality, value)) |
938 | + |
939 | + accepts = [acc for acc in accepts if acc[0] > 0] |
940 | + accepts.sort() |
941 | + accepts.reverse() |
942 | + return [value for quality, value in accepts] |
943 | + |
944 | + |
945 | class VersionedDict(object): |
946 | """A stack of named dictionaries. |
947 | |
948 | |
949 | === modified file 'src/lazr/restful/version.txt' |
950 | --- src/lazr/restful/version.txt 2011-02-16 13:43:38 +0000 |
951 | +++ src/lazr/restful/version.txt 2011-02-17 19:35:43 +0000 |
952 | @@ -1,1 +1,1 @@ |
953 | -0.16.1 |
954 | +0.17.0 |
Leonard--
This looks good. Massive, but good. I've got some comments in the diff below, but they're not landing blockers.
> === modified file 'src/lazr/ restful/ _resource. py' restful/ _resource. py 2011-02-01 16:40:16 +0000 restful/ _resource. py 2011-02-17 15:42:47 +0000 field.endswith( '_html' ):
> --- src/lazr/
> +++ src/lazr/
>
> @@ -1165,8 +1140,9 @@
> # fields that don't correspond to some field in the
> # schema. They're all errors.
> for invalid_field in changeset.keys():
> - errors.append(u"%s: You tried to modify a nonexistent "
> - "attribute." % invalid_field)
> + if not invalid_
> + errors.append(u"%s: You tried to modify a nonexistent "
> + "attribute." % invalid_field)
Just commenting that you already told me you're improving this in the followup, since others may look at this MP. I agree that it's not the right way to do this, and I'm glad you're fixing it in a subsequent branch.
> === modified file 'src/lazr/ restful/ utils.py' restful/ utils.py 2010-09-28 12:14:12 +0000 restful/ utils.py 2011-02-17 15:42:47 +0000 index(total_ size_link_ version) <= versions. index(version) style_header( value): s#getPreferredL anguages. guages there was some language
> --- src/lazr/
> +++ src/lazr/
> return versions.
>
>
> +def parse_accept_
> + """Parse an HTTP header from the Accept-* family.
> +
> + These headers contain a list of possible values, each with an
> + optional priority.
> +
> + This code is modified from Zope's
> + BrowserLanguage
> +
> + If a value includes parameters other than 'q', they will be preserved.
> +
> + :return: All values, in descending order of priority.
> + """
> + if value is None:
> + return []
> +
> + values = value.split(',')
> + # In the original getPreferredLan
> + # code normalization here, which I removed.
I don't know that this comment is needed; it doesn't really make sense when looking through it now. It seems like the sort of thing needed in commit history or MPs, but not code.
> + values = [v.strip() for v in values if v != ""] parameters = value.split(';') and_parameters) > 1: parameters[ 0] parameters[ 1:] parameters) : startswith( 'q='): parameter. split(' =', 2)[1]
> +
> + accepts = []
> + for index, value in enumerate(values):
> + value_and_
> +
> + # If not supplied, quality defaults to 1.
> + quality = 1.0
> +
> + if len(value_
> + # There's at least one parameter, possibly including
> + # a quality parameter.
> + quality_parameter = None
> + bare_value = value_and_
> + parameters = value_and_
> + for parameter_index, parameter in enumerate(
> + if parameter.
> + # We found the quality parameter
> + quality_parameter = parameter
> + q = quality_
> + quality = float(q)
> +
> + if quality_parameter is not None:
> + # Remove the quality parameter from the list of
> + # parameters, and re-create the header value without
> ...