Merge lp:~allenap/maas/cli-upload-files--bug-1187826 into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 2226
Proposed branch: lp:~allenap/maas/cli-upload-files--bug-1187826
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 366 lines (+149/-50)
4 files modified
src/apiclient/multipart.py (+21/-1)
src/apiclient/tests/test_multipart.py (+14/-11)
src/maascli/api.py (+50/-15)
src/maascli/tests/test_api.py (+64/-23)
To merge this branch: bzr merge lp:~allenap/maas/cli-upload-files--bug-1187826
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Approve
Review via email: mp+213927@code.launchpad.net

Commit message

Allow uploading of files from the command-line using a new parameter@=filename syntax.

For example, this enables creating commissioning scripts from the command-line API client.

Description of the change

The new syntax, parameter@=filename, takes inspiration from a number of command-line tools I've used over the years, where prefixing an argument with @ denotes that the following word is a filename. I put it to the left of the equals sign to avoid mix-ups with arguments that begin with @. The @ symbol is also not a special character to (most|all) shells, so it's safe to leave unquoted for convenience; the use of < for example would have always required quoting.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

To try it out:

  bin/maas foo files add filename=foobar file@=setup.py
  bin/maas foo files get filename=foobar

Revision history for this message
Gavin Panella (allenap) wrote :

Or:

  bin/maas foo commissioning-scripts create name=foobar content@=setup.py
  bin/maas foo commissioning-script read foobar

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Thanks. This is something we've been hoping somebody else would do for so long!

.

The comment in test_encode_multipart_data_multiple_params made me blink a few times. Maybe I just lack context. What do you mean by "callables are called then used as context managers"? Are the return values used as context managers? The passive voice tends to obscure things: who calls the callables?

Also, try reading that first sentence in the comment out loud — whether the original version or your new version. If you got the emphasis right, I think that's because you already knew what the comment meant before you started. The way it's phrased seems to say: if you have sequences of parameters or files, you can pass those to encode_multipart_data() as opposed to somewhere else.

What I think the text actually means (but without much confidence based on the text, which is my point) is: encode_multipart_data() accepts files and regular parameters in the form of sequences, so that you can pass multiple values for the same parameter. But the sentence uses the passive voice *twice*, so as a reader I'm left with two who-does-what unknowns in the equation. Adding unknowns makes equations harder to solve.

.

I am similarly befuddled by maybe_file(). It might help if the name started with a verb, although the docstring suggests that that verb would be “check” — when clearly that's not what it does.

Having said that, of course, I have probably obligated myself to come up with something better. Here's a shot, with a name that I hope fits in well with ‘prepare_payload’:

    @staticmethod
    def prepare_parameter(name, value):
        """Return parameter in a form usable by `build_multipart_message`.

        Returns a (`name`, `value`) tuple. For file uploads, the returned
        `value` is a callable that returns a `file` object for the upload.
        Otherwise, the returned `value` is simply the value that was passed
        in.

        File uploads are distinguished by an ampersand (`@`) suffix to the
        parameter `name`::

            parameter@=filename

        In that case, the `value` parameter is the file's
        path. The returned `name` will not have the ampersand.
        """

This version also hints at _why_ a file is returned as a callable: because that's how build_multipart_message likes it.

.

In test_files_are_included, I would suggest using factory.make_name('param') instead of factory.getRandomString() for generating the ‘parameter’ value.

.

In that same test, I'm not very comfortable with expected_body_template. Is the ordering of those lines actually defined? It'd be better if we could either:

(a) parse the MIME message back and see that we get the expected result; or
(b) excise the relevant parts and use Contains matchers.

Jeroen

review: Approve
Revision history for this message
Raphaël Badin (rvb) wrote :

> Thanks. This is something we've been hoping somebody else would do for so long!

Oh yes! This is probably worth backporting to 1.5 since the bug prevents using the CLI for something as important as uploading custom commissioning scripts.

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (3.2 KiB)

> Thanks.  This is something we've been hoping somebody else would do for so
> long!
>
> .
>
> The comment in test_encode_multipart_data_multiple_params made me blink a few
> times.  Maybe I just lack context.  What do you mean by "callables are called
> then used as context managers"?  Are the return values used as context
> managers?  The passive voice tends to obscure things: who calls the callables?
>
> Also, try reading that first sentence in the comment out loud — whether the
> original version or your new version.  If you got the emphasis right, I think
> that's because you already knew what the comment meant before you started.
> The way it's phrased seems to say: if you have sequences of parameters or
> files, you can pass those to encode_multipart_data() as opposed to somewhere
> else.
>
> What I think the text actually means (but without much confidence based on the
> text, which is my point) is: encode_multipart_data() accepts files and regular
> parameters in the form of sequences, so that you can pass multiple values for
> the same parameter.  But the sentence uses the passive voice *twice*, so as a
> reader I'm left with two who-does-what unknowns in the equation.  Adding
> unknowns makes equations harder to solve.

I've done what I can to improve it, largely reducing the use of the
passive voice.

>
> .
>
> I am similarly befuddled by maybe_file().  It might help if the name started
> with a verb, although the docstring suggests that that verb would be “check” —
> when clearly that's not what it does.
>
> Having said that, of course, I have probably obligated myself to come up with
> something better.  Here's a shot, with a name that I hope fits in well with
> ‘prepare_payload’:
>
>     @staticmethod
>     def prepare_parameter(name, value):
>         """Return parameter in a form usable by `build_multipart_message`.
>
>         Returns a (`name`, `value`) tuple.  For file uploads, the returned
>         `value` is a callable that returns a `file` object for the upload.
>         Otherwise, the returned `value` is simply the value that was passed
>         in.
>
>         File uploads are distinguished by an ampersand (`@`) suffix to the
>         parameter `name`::
>
>             parameter@=filename
>
>         In that case, the `value` parameter is the file's
>         path.  The returned `name` will not have the ampersand.
>         """
>
> This version also hints at _why_ a file is returned as a callable: because
> that's how build_multipart_message likes it.

I have changed how this works. It's broadly similar to what you've
written, but happens at a different point. maybe_file() is gone.

>
> .
>
> In test_files_are_included, I would suggest using factory.make_name('param')
> instead of factory.getRandomString() for generating the ‘parameter’ value.

Done.

>
> .
>
> In that same test, I'm not very comfortable with expected_body_template.  Is
> the ordering of those lines actually defined?  It'd be better if we could
> either:
>
> (a) parse the MIME message back and see that we get the expected result; or
> (b) excise the relevant parts and use Contains matchers.

Messages headers are stored in order, so this'll be fine...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/apiclient/multipart.py'
--- src/apiclient/multipart.py 2013-10-07 09:12:40 +0000
+++ src/apiclient/multipart.py 2014-04-03 19:58:37 +0000
@@ -65,11 +65,31 @@
6565
6666
67def make_payloads(name, content):67def make_payloads(name, content):
68 """Constructs payload(s) for the given `name` and `content`.
69
70 If `content` is a byte string, this calls `make_bytes_payload` to
71 construct the payload, which this then yields.
72
73 If `content` is a unicode string, this calls `make_string_payload`.
74
75 If `content` is file-like -- it inherits from `IOBase` or `file` --
76 this calls `make_file_payload`.
77
78 If `content` is iterable, this calls `make_payloads` for each item,
79 with the same name, and then re-yields each payload generated.
80
81 If `content` is callable, this calls it with no arguments, and then
82 uses the result as a context manager. This can be useful if the
83 callable returns an open file, for example, because the context
84 protocol means it will be closed after use.
85
86 This raises `AssertionError` if it encounters anything else.
87 """
68 if isinstance(content, bytes):88 if isinstance(content, bytes):
69 yield make_bytes_payload(name, content)89 yield make_bytes_payload(name, content)
70 elif isinstance(content, unicode):90 elif isinstance(content, unicode):
71 yield make_string_payload(name, content)91 yield make_string_payload(name, content)
72 elif isinstance(content, IOBase):92 elif isinstance(content, (IOBase, file)):
73 yield make_file_payload(name, content)93 yield make_file_payload(name, content)
74 elif callable(content):94 elif callable(content):
75 with content() as content:95 with content() as content:
7696
=== modified file 'src/apiclient/tests/test_multipart.py'
--- src/apiclient/tests/test_multipart.py 2013-10-18 16:57:37 +0000
+++ src/apiclient/tests/test_multipart.py 2014-04-03 19:58:37 +0000
@@ -85,18 +85,20 @@
85 ahem_django_ahem)85 ahem_django_ahem)
8686
87 def test_encode_multipart_data_multiple_params(self):87 def test_encode_multipart_data_multiple_params(self):
88 # Sequences of parameters and files can be passed to88 # Sequences of parameters and files passed to
89 # encode_multipart_data() so that multiple parameters/files with the89 # encode_multipart_data() permit use of the same name for
90 # same name can be provided.90 # multiple parameters and/or files. See `make_payloads` to
91 # understand how it processes different types of parameter
92 # values.
91 params_in = [93 params_in = [
92 ("one", "ABC"),94 ("one", "ABC"),
93 ("one", "XYZ"),95 ("one", "XYZ"),
94 ("two", "DEF"),96 ("two", ["DEF", "UVW"]),
95 ("two", "UVW"),
96 ]97 ]
97 files_in = [98 files_in = [
98 ("f-one", BytesIO(urandom(32))),99 ("f-one", BytesIO(b"f1")),
99 ("f-two", BytesIO(urandom(32))),100 ("f-two", open(self.make_file(contents=b"f2"), "rb")),
101 ("f-three", lambda: open(self.make_file(contents=b"f3"), "rb")),
100 ]102 ]
101 body, headers = encode_multipart_data(params_in, files_in)103 body, headers = encode_multipart_data(params_in, files_in)
102 self.assertEqual("%s" % len(body), headers["Content-Length"])104 self.assertEqual("%s" % len(body), headers["Content-Length"])
@@ -107,13 +109,14 @@
107 params_out, files_out = (109 params_out, files_out = (
108 parse_headers_and_body_with_django(headers, body))110 parse_headers_and_body_with_django(headers, body))
109 params_out_expected = MultiValueDict()111 params_out_expected = MultiValueDict()
110 for name, value in params_in:112 params_out_expected.appendlist("one", "ABC")
111 params_out_expected.appendlist(name, value)113 params_out_expected.appendlist("one", "XYZ")
114 params_out_expected.appendlist("two", "DEF")
115 params_out_expected.appendlist("two", "UVW")
112 self.assertEqual(116 self.assertEqual(
113 params_out_expected, params_out,117 params_out_expected, params_out,
114 ahem_django_ahem)118 ahem_django_ahem)
115 self.assertSetEqual({"f-one", "f-two"}, set(files_out))119 files_expected = {"f-one": b"f1", "f-two": b"f2", "f-three": b"f3"}
116 files_expected = {name: buf.getvalue() for name, buf in files_in}
117 files_observed = {name: buf.read() for name, buf in files_out.items()}120 files_observed = {name: buf.read() for name, buf in files_out.items()}
118 self.assertEqual(121 self.assertEqual(
119 files_expected, files_observed,122 files_expected, files_observed,
120123
=== modified file 'src/maascli/api.py'
--- src/maascli/api.py 2014-02-27 13:22:53 +0000
+++ src/maascli/api.py 2014-04-03 19:58:37 +0000
@@ -19,10 +19,12 @@
19import argparse19import argparse
20from collections import defaultdict20from collections import defaultdict
21from email.message import Message21from email.message import Message
22from functools import partial
22import httplib23import httplib
23from itertools import chain24from itertools import chain
24import json25import json
25from operator import itemgetter26from operator import itemgetter
27import re
26import sys28import sys
27from textwrap import (29from textwrap import (
28 dedent,30 dedent,
@@ -34,7 +36,10 @@
34 )36 )
3537
36from apiclient.maas_client import MAASOAuth38from apiclient.maas_client import MAASOAuth
37from apiclient.multipart import encode_multipart_data39from apiclient.multipart import (
40 build_multipart_message,
41 encode_multipart_message,
42 )
38from apiclient.utils import (43from apiclient.utils import (
39 ascii_url,44 ascii_url,
40 urlencode,45 urlencode,
@@ -166,6 +171,10 @@
166 uri, body, headers = self.prepare_payload(171 uri, body, headers = self.prepare_payload(
167 self.op, self.method, uri, options.data)172 self.op, self.method, uri, options.data)
168173
174 # Headers are returned as a list, but they must be a dict for
175 # the signing machinery.
176 headers = dict(headers)
177
169 # Sign request if credentials have been provided.178 # Sign request if credentials have been provided.
170 if self.credentials is not None:179 if self.credentials is not None:
171 self.sign(uri, headers, self.credentials)180 self.sign(uri, headers, self.credentials)
@@ -190,15 +199,32 @@
190199
191 @staticmethod200 @staticmethod
192 def name_value_pair(string):201 def name_value_pair(string):
193 parts = string.split("=", 1)202 """Ensure that `string` is a valid ``name:value`` pair.
194 if len(parts) == 2:203
195 return tuple(parts)204 When `string` is of the form ``name=value``, this returns a
205 2-tuple of ``name, value``.
206
207 However, when `string` is of the form ``name@=value``, this
208 returns a ``name, opener`` tuple, where ``opener`` is a function
209 that will return an open file handle when called. The file will
210 be opened in binary mode for reading only.
211 """
212 parts = re.split(r'(=|@=)', string, 1)
213 if len(parts) == 3:
214 name, what, value = parts
215 if what == "=":
216 return name, value
217 elif what == "@=":
218 return name, partial(open, value, "rb")
219 else:
220 raise AssertionError(
221 "Unrecognised separator %r" % what)
196 else:222 else:
197 raise CommandError(223 raise CommandError(
198 "%r is not a name=value pair" % string)224 "%r is not a name=value or name@=filename pair" % string)
199225
200 @staticmethod226 @classmethod
201 def prepare_payload(op, method, uri, data):227 def prepare_payload(cls, op, method, uri, data):
202 """Return the URI (modified perhaps) and body and headers.228 """Return the URI (modified perhaps) and body and headers.
203229
204 - For GET requests, encode parameters in the query string.230 - For GET requests, encode parameters in the query string.
@@ -209,18 +235,27 @@
209235
210 :param method: The HTTP method.236 :param method: The HTTP method.
211 :param uri: The URI of the action.237 :param uri: The URI of the action.
212 :param data: A dict or iterable of name=value pairs to pack into the238 :param data: An iterable of ``name, value`` or ``name, opener``
213 body or headers, depending on the type of request.239 tuples (see `name_value_pair`) to pack into the body or
240 query, depending on the type of request.
214 """241 """
242 query = [] if op is None else [("op", op)]
243
244 def slurp(opener):
245 with opener() as fd:
246 return fd.read()
247
215 if method == "GET":248 if method == "GET":
216 query = data if op is None else chain([("op", op)], data)249 query.extend(
217 body, headers = None, {}250 (name, slurp(value) if callable(value) else value)
251 for name, value in data)
252 body, headers = None, []
218 else:253 else:
219 query = [] if op is None else [("op", op)]254 if data is None or len(data) == 0:
220 if data:255 body, headers = None, []
221 body, headers = encode_multipart_data(data)
222 else:256 else:
223 body, headers = None, {}257 message = build_multipart_message(data)
258 headers, body = encode_multipart_message(message)
224259
225 uri = urlparse(uri)._replace(query=urlencode(query)).geturl()260 uri = urlparse(uri)._replace(query=urlencode(query)).geturl()
226 return uri, body, headers261 return uri, body, headers
227262
=== modified file 'src/maascli/tests/test_api.py'
--- src/maascli/tests/test_api.py 2014-02-27 13:22:53 +0000
+++ src/maascli/tests/test_api.py 2014-04-03 19:58:37 +0000
@@ -15,6 +15,7 @@
15__all__ = []15__all__ = []
1616
17import collections17import collections
18from functools import partial
18import httplib19import httplib
19import io20import io
20import json21import json
@@ -515,25 +516,26 @@
515 {"method": "POST", "data": [],516 {"method": "POST", "data": [],
516 "expected_uri": uri_base,517 "expected_uri": uri_base,
517 "expected_body": None,518 "expected_body": None,
518 "expected_headers": {}}),519 "expected_headers": []}),
519 ("read",520 ("read",
520 {"method": "GET", "data": [],521 {"method": "GET", "data": [],
521 "expected_uri": uri_base,522 "expected_uri": uri_base,
522 "expected_body": None,523 "expected_body": None,
523 "expected_headers": {}}),524 "expected_headers": []}),
524 ("update",525 ("update",
525 {"method": "PUT", "data": [],526 {"method": "PUT", "data": [],
526 "expected_uri": uri_base,527 "expected_uri": uri_base,
527 "expected_body": None,528 "expected_body": None,
528 "expected_headers": {}}),529 "expected_headers": []}),
529 ("delete",530 ("delete",
530 {"method": "DELETE", "data": [],531 {"method": "DELETE", "data": [],
531 "expected_uri": uri_base,532 "expected_uri": uri_base,
532 "expected_body": None,533 "expected_body": None,
533 "expected_headers": {}}),534 "expected_headers": []}),
534 # With data, PUT, POST, and DELETE requests have their body and extra535 # With data, PUT, POST, and DELETE requests have their body and
535 # headers prepared by encode_multipart_data. For GET requests, the536 # extra headers prepared by build_multipart_message and
536 # data is encoded into the query string, and both the request body and537 # encode_multipart_message. For GET requests, the data is
538 # encoded into the query string, and both the request body and
537 # extra headers are empty.539 # extra headers are empty.
538 ("create-with-data",540 ("create-with-data",
539 {"method": "POST", "data": [("foo", "bar"), ("foo", "baz")],541 {"method": "POST", "data": [("foo", "bar"), ("foo", "baz")],
@@ -544,7 +546,7 @@
544 {"method": "GET", "data": [("foo", "bar"), ("foo", "baz")],546 {"method": "GET", "data": [("foo", "bar"), ("foo", "baz")],
545 "expected_uri": uri_base + "?foo=bar&foo=baz",547 "expected_uri": uri_base + "?foo=bar&foo=baz",
546 "expected_body": None,548 "expected_body": None,
547 "expected_headers": {}}),549 "expected_headers": []}),
548 ("update-with-data",550 ("update-with-data",
549 {"method": "PUT", "data": [("foo", "bar"), ("foo", "baz")],551 {"method": "PUT", "data": [("foo", "bar"), ("foo", "baz")],
550 "expected_uri": uri_base,552 "expected_uri": uri_base,
@@ -565,27 +567,28 @@
565 {"method": "POST", "data": [],567 {"method": "POST", "data": [],
566 "expected_uri": uri_base + "?op=something",568 "expected_uri": uri_base + "?op=something",
567 "expected_body": None,569 "expected_body": None,
568 "expected_headers": {}}),570 "expected_headers": []}),
569 ("read",571 ("read",
570 {"method": "GET", "data": [],572 {"method": "GET", "data": [],
571 "expected_uri": uri_base + "?op=something",573 "expected_uri": uri_base + "?op=something",
572 "expected_body": None,574 "expected_body": None,
573 "expected_headers": {}}),575 "expected_headers": []}),
574 ("update",576 ("update",
575 {"method": "PUT", "data": [],577 {"method": "PUT", "data": [],
576 "expected_uri": uri_base + "?op=something",578 "expected_uri": uri_base + "?op=something",
577 "expected_body": None,579 "expected_body": None,
578 "expected_headers": {}}),580 "expected_headers": []}),
579 ("delete",581 ("delete",
580 {"method": "DELETE", "data": [],582 {"method": "DELETE", "data": [],
581 "expected_uri": uri_base + "?op=something",583 "expected_uri": uri_base + "?op=something",
582 "expected_body": None,584 "expected_body": None,
583 "expected_headers": {}}),585 "expected_headers": []}),
584 # With data, PUT, POST, and DELETE requests have their body and extra586 # With data, PUT, POST, and DELETE requests have their body and
585 # headers prepared by encode_multipart_data. For GET requests, the587 # extra headers prepared by build_multipart_message and
586 # data is encoded into the query string, and both the request body and588 # encode_multipart_message. For GET requests, the data is
587 # extra headers are empty. The operation is encoded into the query589 # encoded into the query string, and both the request body and
588 # string.590 # extra headers are empty. The operation is encoded into the
591 # query string.
589 ("create-with-data",592 ("create-with-data",
590 {"method": "POST", "data": [("foo", "bar"), ("foo", "baz")],593 {"method": "POST", "data": [("foo", "bar"), ("foo", "baz")],
591 "expected_uri": uri_base + "?op=something",594 "expected_uri": uri_base + "?op=something",
@@ -595,7 +598,7 @@
595 {"method": "GET", "data": [("foo", "bar"), ("foo", "baz")],598 {"method": "GET", "data": [("foo", "bar"), ("foo", "baz")],
596 "expected_uri": uri_base + "?op=something&foo=bar&foo=baz",599 "expected_uri": uri_base + "?op=something&foo=bar&foo=baz",
597 "expected_body": None,600 "expected_body": None,
598 "expected_headers": {}}),601 "expected_headers": []}),
599 ("update-with-data",602 ("update-with-data",
600 {"method": "PUT", "data": [("foo", "bar"), ("foo", "baz")],603 {"method": "PUT", "data": [("foo", "bar"), ("foo", "baz")],
601 "expected_uri": uri_base + "?op=something",604 "expected_uri": uri_base + "?op=something",
@@ -619,9 +622,12 @@
619 scenarios = scenarios_without_op + scenarios_with_op622 scenarios = scenarios_without_op + scenarios_with_op
620623
621 def test_prepare_payload(self):624 def test_prepare_payload(self):
622 # Patch encode_multipart_data to match the scenarios.625 # Patch build_multipart_message and encode_multipart_message to
623 encode_multipart_data = self.patch(api, "encode_multipart_data")626 # match the scenarios.
624 encode_multipart_data.return_value = sentinel.body, sentinel.headers627 build_multipart_message = self.patch(api, "build_multipart_message")
628 build_multipart_message.return_value = sentinel.message
629 encode_multipart_message = self.patch(api, "encode_multipart_message")
630 encode_multipart_message.return_value = sentinel.headers, sentinel.body
625 # The payload returned is a 3-tuple of (uri, body, headers).631 # The payload returned is a 3-tuple of (uri, body, headers).
626 payload = api.Action.prepare_payload(632 payload = api.Action.prepare_payload(
627 op=self.op, method=self.method,633 op=self.op, method=self.method,
@@ -632,8 +638,43 @@
632 Equals(self.expected_headers),638 Equals(self.expected_headers),
633 )639 )
634 self.assertThat(payload, MatchesListwise(expected))640 self.assertThat(payload, MatchesListwise(expected))
635 # encode_multipart_data, when called, is passed the data641 # encode_multipart_message, when called, is passed the data
636 # unadulterated.642 # unadulterated.
637 if self.expected_body is sentinel.body:643 if self.expected_body is sentinel.body:
638 self.assertThat(644 self.assertThat(
639 api.encode_multipart_data, MockCalledOnceWith(self.data))645 api.build_multipart_message,
646 MockCalledOnceWith(self.data))
647 self.assertThat(
648 api.encode_multipart_message,
649 MockCalledOnceWith(sentinel.message))
650
651
652class TestPayloadPreparationWithFiles(MAASTestCase):
653 """Tests for `maascli.api.Action.prepare_payload` involving files."""
654
655 def test_files_are_included(self):
656 parameter = factory.make_name("param")
657 contents = factory.getRandomBytes()
658 filename = self.make_file(contents=contents)
659 # Writing the parameter as "parameter@=filename" on the
660 # command-line causes name_value_pair() to return a `name,
661 # opener` tuple, where `opener` is a callable that returns an
662 # open file handle.
663 data = [(parameter, partial(open, filename, "rb"))]
664 uri, body, headers = api.Action.prepare_payload(
665 op=None, method="POST", uri="http://localhost", data=data)
666
667 expected_body_template = """\
668 --...
669 Content-Transfer-Encoding: base64
670 Content-Disposition: form-data; name="%s"; filename="%s"
671 MIME-Version: 1.0
672 Content-Type: application/octet-stream
673
674 %s
675 --...--
676 """
677 expected_body = expected_body_template % (
678 parameter, parameter, contents.encode("base64"))
679
680 self.assertDocTestMatches(expected_body, body)