← Back to team overview

testtools-dev team mailing list archive

[Merge] lp:~jml/testtools/more-content-convenience into lp:testtools

 

Jonathan Lange has proposed merging lp:~jml/testtools/more-content-convenience into lp:testtools.

Requested reviews:
  testtools developers (testtools-dev)
Related bugs:
  #694126 Convenience method for attaching files as details
  https://bugs.launchpad.net/bugs/694126

For more details, see:
https://code.launchpad.net/~jml/testtools/more-content-convenience/+merge/44870

This branch adds a bunch of convenience methods for the content APIs.

Specifically,
 * Content.from_file, which makes a Content object based on file contents
 * Content.from_stream, which makes a Content object based on stream contents
 * TestCase.attachFile(name, *args), which is TestCase.addDetail(name, Content.from_file(*args))

I've also made Content.from_text, and made the text_content function a simple pointer to that.

There's some duplication between from_file and from_stream. Not sure what to do about that.

I added the eager loading (aka read_now) option because there's code in Launchpad that explicitly does eager loading in order to avoid bad interaction between fixture tear down and addDetail.  Not sure if it should be the default.

I also don't know what the default chunk size should be.  The value I've chosen is almost certainly wrong.
-- 
https://code.launchpad.net/~jml/testtools/more-content-convenience/+merge/44870
Your team testtools developers is requested to review the proposed merge of lp:~jml/testtools/more-content-convenience into lp:testtools.
=== modified file 'NEWS'
--- NEWS	2010-12-29 18:11:12 +0000
+++ NEWS	2010-12-29 20:03:26 +0000
@@ -21,6 +21,10 @@
 
 * ``MultiTestResult`` now documented in the manual. (Jonathan Lange, #661116)
 
+* New content helpers ``Content.from_file``, ``Content.from_stream`` and
+  ``TestCase.attachFile`` make it easier to attach file-like objects to a
+  test. (Jonathan Lange, #694126)
+
 * Vastly improved and extended documentation. (Jonathan Lange)
 
 

=== modified file 'doc/for-test-authors.rst'
--- doc/for-test-authors.rst	2010-12-22 20:40:09 +0000
+++ doc/for-test-authors.rst	2010-12-29 20:03:26 +0000
@@ -634,12 +634,16 @@
 Because adding small bits of text content is very common, there's also a
 convenience method::
 
-  text = text_content("some text")
+  text = Content.from_text("some text")
 
 To make content out of an image stored on disk, you could do something like::
 
   image = Content(ContentType('image', 'png'), lambda: open('foo.png').read())
 
+Or you could use the convenience method::
+
+  image = Content.from_file('foo.png', ContentType('image', 'png'))
+
 The ``lambda`` helps make sure that the file is opened and the actual bytes
 read only when they are needed – by default, when the test is finished.  This
 means that tests can construct and add Content objects freely without worrying

=== modified file 'testtools/compat.py'
--- testtools/compat.py	2010-12-11 00:07:01 +0000
+++ testtools/compat.py	2010-12-29 20:03:26 +0000
@@ -2,24 +2,28 @@
 
 """Compatibility support for python 2 and 3."""
 
-
-import codecs
-import linecache
-import locale
-import os
-import re
-import sys
-import traceback
-
 __metaclass__ = type
 __all__ = [
     '_b',
     '_u',
     'advance_iterator',
     'str_is_unicode',
+    'StringIO',
     'unicode_output_stream',
     ]
 
+import codecs
+import linecache
+import locale
+import os
+import re
+import sys
+import traceback
+
+from testtools.helpers import try_imports
+
+StringIO = try_imports(['StringIO.StringIO', 'io.StringIO'])
+
 
 __u_doc = """A function version of the 'u' prefix.
 

=== modified file 'testtools/content.py'
--- testtools/content.py	2010-11-16 00:18:10 +0000
+++ testtools/content.py	2010-12-29 20:03:26 +0000
@@ -12,6 +12,21 @@
 _join_b = _b("").join
 
 
+DEFAULT_CHUNK_SIZE = 4096
+
+
+def _iter_chunks(stream, chunk_size):
+    """Read 'stream' in chunks of 'chunk_size'.
+
+    :param stream: A file-like object to read from.
+    :param chunk_size: The size of each read from 'stream'.
+    """
+    chunk = stream.read(chunk_size)
+    while chunk:
+        yield chunk
+        chunk = stream.read(chunk_size)
+
+
 class Content(object):
     """A MIME-like Content object.
 
@@ -36,6 +51,68 @@
         return (self.content_type == other.content_type and
             _join_b(self.iter_bytes()) == _join_b(other.iter_bytes()))
 
+    @classmethod
+    def from_file(cls, path, content_type=None, chunk_size=None,
+                  read_now=False):
+        """Create a `Content` object from a file on disk.
+
+        Note that unless 'read_now' is explicitly passed in as True, the file
+        will only be read from when ``iter_bytes`` is called.
+
+        :param path: The path to the file to be used as content.
+        :param content_type: The type of content.  If not specified, defaults
+            to UTF8-encoded text/plain.
+        :param chunk_size: The size of chunks to read from the file.
+            Defaults to `DEFAULT_CHUNK_SIZE`.
+        :param read_now: If True, read the file from disk now and keep it in
+            memory.
+        """
+        if content_type is None:
+            content_type = UTF8_TEXT
+        if chunk_size is None:
+            chunk_size = DEFAULT_CHUNK_SIZE
+        def reader():
+            stream = open(path, 'rb')
+            for chunk in _iter_chunks(stream, chunk_size):
+                yield chunk
+            stream.close()
+        if read_now:
+            contents = list(reader())
+            reader = lambda: contents
+        return cls(content_type, reader)
+
+    @classmethod
+    def from_stream(cls, stream, content_type=None, chunk_size=None,
+                    read_now=False):
+        """Create a `Content` object from a file-like stream.
+
+        Note that the stream will only be read from when ``iter_bytes`` is
+        called.
+
+        :param stream: A file-like object to read the content from.
+        :param content_type: The type of content. If not specified, defaults
+            to UTF8-encoded text/plain.
+        :param chunk_size: The size of chunks to read from the file.
+             Defaults to `DEFAULT_CHUNK_SIZE`.
+        """
+        if content_type is None:
+            content_type = UTF8_TEXT
+        if chunk_size is None:
+            chunk_size = DEFAULT_CHUNK_SIZE
+        reader = lambda: _iter_chunks(stream, chunk_size)
+        if read_now:
+            contents = list(reader())
+            reader = lambda: contents
+        return cls(content_type, reader)
+
+    @classmethod
+    def from_text(cls, text):
+        """Create a `Content` object from some text.
+
+        This is useful for adding details which are short strings.
+        """
+        return cls(UTF8_TEXT, lambda: [text.encode('utf8')])
+
     def iter_bytes(self):
         """Iterate over bytestrings of the serialised content."""
         return self._get_bytes()
@@ -99,4 +176,4 @@
 
     This is useful for adding details which are short strings.
     """
-    return Content(UTF8_TEXT, lambda: [text.encode('utf8')])
+    return Content.from_text(text)

=== modified file 'testtools/deferredruntest.py'
--- testtools/deferredruntest.py	2010-11-30 17:13:22 +0000
+++ testtools/deferredruntest.py	2010-12-29 20:03:26 +0000
@@ -15,7 +15,7 @@
 
 import sys
 
-from testtools import try_imports
+from testtools.compat import StringIO
 from testtools.content import (
     Content,
     text_content,
@@ -34,8 +34,6 @@
 from twisted.python import log
 from twisted.trial.unittest import _LogObserver
 
-StringIO = try_imports(['StringIO.StringIO', 'io.StringIO'])
-
 
 class _DeferredRunTest(RunTest):
     """Base for tests that return Deferreds."""

=== modified file 'testtools/testcase.py'
--- testtools/testcase.py	2010-12-12 04:11:39 +0000
+++ testtools/testcase.py	2010-12-29 20:03:26 +0000
@@ -167,6 +167,29 @@
             self.__details = {}
         self.__details[name] = content_object
 
+    def attachFile(self, name, path, content_type=None, chunk_size=None,
+                   read_now=False):
+        """Attach a file to this test as a detail.
+
+        This is a convenience method wrapping around `addDetail`.
+
+        Note that unless 'read_now' is explicitly passed in as True, the file
+        *must* exist when the test result is called with the results of this
+        test, after the test has been torn down.
+
+        :param name: The name to give to the detail for the attached file.
+        :param path: The path to the file to attach.
+        :param content_type: The content type of the file.  If not provided,
+            defaults to UTF8-encoded text/plain.
+        :param chunk_size: The size of chunks to read from the file.  Defaults
+            to something sensible.
+        :param read_now: Whether to read the file into memory now, or wait
+            until the test reports its results.  Defaults to False.
+        """
+        content_object = content.Content.from_file(
+            path, content_type, chunk_size)
+        self.addDetail(name, content_object)
+
     def getDetails(self):
         """Get the details dict that will be reported with this test's outcome.
 

=== modified file 'testtools/tests/test_content.py'
--- testtools/tests/test_content.py	2010-11-18 10:53:46 +0000
+++ testtools/tests/test_content.py	2010-12-29 20:03:26 +0000
@@ -1,11 +1,30 @@
 # Copyright (c) 2008-2010 Jonathan M. Lange. See LICENSE for details.
 
+import os
+import tempfile
 import unittest
+
 from testtools import TestCase
-from testtools.compat import _b, _u
-from testtools.content import Content, TracebackContent, text_content
-from testtools.content_type import ContentType, UTF8_TEXT
-from testtools.matchers import MatchesException, Raises
+from testtools.compat import (
+    _b,
+    _u,
+    StringIO,
+    )
+from testtools.content import (
+    Content,
+    TracebackContent,
+    text_content,
+    )
+from testtools.content_type import (
+    ContentType,
+    UTF8_TEXT,
+    )
+from testtools.matchers import (
+    Equals,
+    MatchesException,
+    Raises,
+    raises,
+    )
 from testtools.tests.helpers import an_exc_info
 
 
@@ -15,10 +34,11 @@
 class TestContent(TestCase):
 
     def test___init___None_errors(self):
-        self.assertThat(lambda:Content(None, None), raises_value_error)
-        self.assertThat(lambda:Content(None, lambda: ["traceback"]),
-            raises_value_error)
-        self.assertThat(lambda:Content(ContentType("text", "traceback"), None),
+        self.assertThat(lambda: Content(None, None), raises_value_error)
+        self.assertThat(
+            lambda: Content(None, lambda: ["traceback"]), raises_value_error)
+        self.assertThat(
+            lambda: Content(ContentType("text", "traceback"), None),
             raises_value_error)
 
     def test___init___sets_ivars(self):
@@ -64,12 +84,67 @@
         content = Content(content_type, lambda: [iso_version])
         self.assertEqual([text], list(content.iter_text()))
 
+    def test_from_file(self):
+        fd, path = tempfile.mkstemp()
+        self.addCleanup(os.remove, path)
+        os.write(fd, 'some data')
+        os.close(fd)
+        content = Content.from_file(path, UTF8_TEXT, chunk_size=2)
+        self.assertThat(
+            list(content.iter_bytes()), Equals(['so', 'me', ' d', 'at', 'a']))
+
+    def test_from_nonexistent_file(self):
+        directory = tempfile.mkdtemp()
+        nonexistent = os.path.join(directory, 'nonexistent-file')
+        content = Content.from_file(nonexistent)
+        self.assertThat(content.iter_bytes, raises(IOError))
+
+    def test_from_file_default_type(self):
+        content = Content.from_file('/nonexistent/path')
+        self.assertThat(content.content_type, Equals(UTF8_TEXT))
+
+    def test_from_file_eager_loading(self):
+        fd, path = tempfile.mkstemp()
+        os.write(fd, 'some data')
+        os.close(fd)
+        content = Content.from_file(path, UTF8_TEXT, read_now=True)
+        os.remove(path)
+        self.assertThat(
+            _b('').join(content.iter_bytes()), Equals('some data'))
+
+    def test_from_stream(self):
+        data = StringIO('some data')
+        content = Content.from_stream(data, UTF8_TEXT, chunk_size=2)
+        self.assertThat(
+            list(content.iter_bytes()), Equals(['so', 'me', ' d', 'at', 'a']))
+
+    def test_from_stream_default_type(self):
+        data = StringIO('some data')
+        content = Content.from_stream(data)
+        self.assertThat(content.content_type, Equals(UTF8_TEXT))
+
+    def test_from_stream_eager_loading(self):
+        fd, path = tempfile.mkstemp()
+        self.addCleanup(os.remove, path)
+        os.write(fd, 'some data')
+        stream = open(path, 'rb')
+        content = Content.from_stream(stream, UTF8_TEXT, read_now=True)
+        os.write(fd, 'more data')
+        os.close(fd)
+        self.assertThat(
+            _b('').join(content.iter_bytes()), Equals('some data'))
+
+    def test_from_text(self):
+        data = _u("some data")
+        expected = Content(UTF8_TEXT, lambda: [data.encode('utf8')])
+        self.assertEqual(expected, Content.from_text(data))
+
 
 class TestTracebackContent(TestCase):
 
     def test___init___None_errors(self):
-        self.assertThat(lambda:TracebackContent(None, None),
-            raises_value_error) 
+        self.assertThat(
+            lambda: TracebackContent(None, None), raises_value_error)
 
     def test___init___sets_ivars(self):
         content = TracebackContent(an_exc_info, self)

=== modified file 'testtools/tests/test_run.py'
--- testtools/tests/test_run.py	2010-12-12 23:41:57 +0000
+++ testtools/tests/test_run.py	2010-12-29 20:03:26 +0000
@@ -2,9 +2,9 @@
 
 """Tests for the test runner logic."""
 
-from testtools.helpers import try_import, try_imports
+from testtools.compat import StringIO
+from testtools.helpers import try_import
 fixtures = try_import('fixtures')
-StringIO = try_imports(['StringIO.StringIO', 'io.StringIO'])
 
 import testtools
 from testtools import TestCase, run
@@ -41,7 +41,7 @@
     def test_run_list(self):
         if fixtures is None:
             self.skipTest("Need fixtures")
-        package = self.useFixture(SampleTestFixture())
+        self.useFixture(SampleTestFixture())
         out = StringIO()
         run.main(['prog', '-l', 'testtools.runexample.test_suite'], out)
         self.assertEqual("""testtools.runexample.TestFoo.test_bar
@@ -51,7 +51,7 @@
     def test_run_load_list(self):
         if fixtures is None:
             self.skipTest("Need fixtures")
-        package = self.useFixture(SampleTestFixture())
+        self.useFixture(SampleTestFixture())
         out = StringIO()
         # We load two tests - one that exists and one that doesn't, and we
         # should get the one that exists and neither the one that doesn't nor
@@ -71,6 +71,7 @@
         self.assertEqual("""testtools.runexample.TestFoo.test_bar
 """, out.getvalue())
 
+
 def test_suite():
     from unittest import TestLoader
     return TestLoader().loadTestsFromName(__name__)

=== modified file 'testtools/tests/test_testresult.py'
--- testtools/tests/test_testresult.py	2010-12-19 19:03:10 +0000
+++ testtools/tests/test_testresult.py	2010-12-29 20:03:26 +0000
@@ -22,7 +22,6 @@
     TextTestResult,
     ThreadsafeForwardingResult,
     testresult,
-    try_imports,
     )
 from testtools.compat import (
     _b,
@@ -30,6 +29,7 @@
     _r,
     _u,
     str_is_unicode,
+    StringIO,
     )
 from testtools.content import Content
 from testtools.content_type import ContentType, UTF8_TEXT
@@ -47,8 +47,6 @@
     )
 from testtools.testresult.real import utc
 
-StringIO = try_imports(['StringIO.StringIO', 'io.StringIO'])
-
 
 class Python26Contract(object):
 

=== modified file 'testtools/tests/test_testtools.py'
--- testtools/tests/test_testtools.py	2010-12-13 01:15:11 +0000
+++ testtools/tests/test_testtools.py	2010-12-29 20:03:26 +0000
@@ -3,7 +3,9 @@
 """Tests for extensions to the base test library."""
 
 from pprint import pformat
+import os
 import sys
+import tempfile
 import unittest
 
 from testtools import (
@@ -818,6 +820,19 @@
         details = self.getDetails()
         self.assertEqual({"foo": mycontent}, details)
 
+    def test_attachFile(self):
+        class SomeTest(TestCase):
+            def test_foo(self):
+                pass
+        test = SomeTest('test_foo')
+        fd, path = tempfile.mkstemp()
+        self.addCleanup(os.remove, path)
+        os.write(fd, 'some data')
+        os.close(fd)
+        my_content = content.Content.from_text('some data')
+        test.attachFile('foo', path)
+        self.assertEqual({'foo': my_content}, test.getDetails())
+
     def test_addError(self):
         class Case(TestCase):
             def test(this):


Follow ups