From 139b9f1ac5c6468eecb85a040bdcd421703457e8 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 18:24:45 +0000 Subject: [PATCH 01/22] PEP 8. --- testtools/tests/test_content.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index eaf50c7..f0d52cf 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -15,10 +15,11 @@ raises_value_error = Raises(MatchesException(ValueError)) 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): @@ -68,8 +69,8 @@ class TestContent(TestCase): 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) From eb4640c6b809bcabef95865365071c9401f44398 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 18:39:23 +0000 Subject: [PATCH 02/22] Republish StringIO through compat to avoid repeated code --- testtools/compat.py | 20 ++++++++++++-------- testtools/deferredruntest.py | 4 +--- testtools/tests/test_run.py | 9 +++++---- testtools/tests/test_testresult.py | 4 +--- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/testtools/compat.py b/testtools/compat.py index ecbfb42..ee10b07 100644 --- a/testtools/compat.py +++ b/testtools/compat.py @@ -2,6 +2,15 @@ """Compatibility support for python 2 and 3.""" +__metaclass__ = type +__all__ = [ + '_b', + '_u', + 'advance_iterator', + 'str_is_unicode', + 'StringIO', + 'unicode_output_stream', + ] import codecs import linecache @@ -11,14 +20,9 @@ import re import sys import traceback -__metaclass__ = type -__all__ = [ - '_b', - '_u', - 'advance_iterator', - 'str_is_unicode', - 'unicode_output_stream', - ] +from testtools.helpers import try_imports + +StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) __u_doc = """A function version of the 'u' prefix. diff --git a/testtools/deferredruntest.py b/testtools/deferredruntest.py index 50153be..2ba16bc 100644 --- a/testtools/deferredruntest.py +++ b/testtools/deferredruntest.py @@ -15,7 +15,7 @@ __all__ = [ 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.internet import defer 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.""" diff --git a/testtools/tests/test_run.py b/testtools/tests/test_run.py index 8f88fb6..5b93a58 100644 --- a/testtools/tests/test_run.py +++ b/testtools/tests/test_run.py @@ -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 @@ class TestRun(TestCase): 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 @@ testtools.runexample.TestFoo.test_quux 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 @@ testtools.runexample.missingtest self.assertEqual("""testtools.runexample.TestFoo.test_bar """, out.getvalue()) + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/testtools/tests/test_testresult.py b/testtools/tests/test_testresult.py index 57c3293..a62320b 100644 --- a/testtools/tests/test_testresult.py +++ b/testtools/tests/test_testresult.py @@ -22,7 +22,6 @@ from testtools import ( TextTestResult, ThreadsafeForwardingResult, testresult, - try_imports, ) from testtools.compat import ( _b, @@ -30,6 +29,7 @@ from testtools.compat import ( _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.tests.helpers import ( ) from testtools.testresult.real import utc -StringIO = try_imports(['StringIO.StringIO', 'io.StringIO']) - class Python26Contract(object): From 20b21ebde47ed80268462c2cd82935ff827c9269 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 18:48:05 +0000 Subject: [PATCH 03/22] Add a `stream_content` helper. --- testtools/content.py | 22 +++++++++++++++++++ testtools/tests/test_content.py | 38 +++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index 86df09f..a4cceba 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -94,6 +94,28 @@ class TracebackContent(Content): content_type, lambda: [value.encode("utf8")]) +DEFAULT_CHUNK_SIZE = 4096 + + +def stream_content(stream, content_type=None, chunk_size=DEFAULT_CHUNK_SIZE): + """Create a `Content` object from a file-like stream. + + :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 + def read_chunk(): + chunk = stream.read(chunk_size) + while chunk: + yield chunk + chunk = stream.read(chunk_size) + return Content(content_type, read_chunk) + + def text_content(text): """Create a `Content` object from some text. diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index f0d52cf..d680523 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -2,10 +2,26 @@ 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, + stream_content, + text_content, + ) +from testtools.content_type import ( + ContentType, + UTF8_TEXT, + ) +from testtools.matchers import ( + Equals, + MatchesException, + Raises, + ) from testtools.tests.helpers import an_exc_info @@ -90,6 +106,20 @@ class TestBytesContent(TestCase): self.assertEqual(expected, text_content(data)) +class TestStreamContent(TestCase): + + def test_stream(self): + data = StringIO('some data') + content = stream_content(data, UTF8_TEXT, chunk_size=2) + self.assertThat( + list(content.iter_bytes()), Equals(['so', 'me', ' d', 'at', 'a'])) + + def test_default_type(self): + data = StringIO('some data') + content = stream_content(data) + self.assertThat(content.content_type, Equals(UTF8_TEXT)) + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) From a5d8577a7871f88d453e40f414018848efb6eaf8 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 18:49:13 +0000 Subject: [PATCH 04/22] Update NEWS. --- NEWS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEWS b/NEWS index 5b06428..d4e20a3 100644 --- a/NEWS +++ b/NEWS @@ -21,6 +21,9 @@ Improvements * ``MultiTestResult`` now documented in the manual. (Jonathan Lange, #661116) +* New content helper ``stream_content`` makes it easier to attach file-like + objects to a test. (Jonathan Lange, #694126) + * Vastly improved and extended documentation. (Jonathan Lange) From 72daa71146f4feb87868e60bd189c5ede28687ff Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 18:55:19 +0000 Subject: [PATCH 05/22] Make it a classmethod, and make text_content one too. --- NEWS | 4 +-- testtools/content.py | 55 +++++++++++++++++++-------------- testtools/tests/test_content.py | 31 ++++++++++--------- 3 files changed, 50 insertions(+), 40 deletions(-) diff --git a/NEWS b/NEWS index d4e20a3..a4b531f 100644 --- a/NEWS +++ b/NEWS @@ -21,8 +21,8 @@ Improvements * ``MultiTestResult`` now documented in the manual. (Jonathan Lange, #661116) -* New content helper ``stream_content`` makes it easier to attach file-like - objects to a test. (Jonathan Lange, #694126) +* New content helper ``Content.from_stream`` makes it easier to attach + file-like objects to a test. (Jonathan Lange, #694126) * Vastly improved and extended documentation. (Jonathan Lange) diff --git a/testtools/content.py b/testtools/content.py index a4cceba..4038a70 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -12,6 +12,9 @@ from testtools.testresult import TestResult _join_b = _b("").join +DEFAULT_CHUNK_SIZE = 4096 + + class Content(object): """A MIME-like Content object. @@ -36,6 +39,34 @@ class Content(object): return (self.content_type == other.content_type and _join_b(self.iter_bytes()) == _join_b(other.iter_bytes())) + @classmethod + def from_stream(cls, stream, content_type=None, + chunk_size=DEFAULT_CHUNK_SIZE): + """Create a `Content` object from a file-like stream. + + :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 + def read_chunk(): + chunk = stream.read(chunk_size) + while chunk: + yield chunk + chunk = stream.read(chunk_size) + return Content(content_type, read_chunk) + + @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() @@ -94,31 +125,9 @@ class TracebackContent(Content): content_type, lambda: [value.encode("utf8")]) -DEFAULT_CHUNK_SIZE = 4096 - - -def stream_content(stream, content_type=None, chunk_size=DEFAULT_CHUNK_SIZE): - """Create a `Content` object from a file-like stream. - - :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 - def read_chunk(): - chunk = stream.read(chunk_size) - while chunk: - yield chunk - chunk = stream.read(chunk_size) - return Content(content_type, read_chunk) - - def text_content(text): """Create a `Content` object from some text. This is useful for adding details which are short strings. """ - return Content(UTF8_TEXT, lambda: [text.encode('utf8')]) + return Content.from_text(text) diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index d680523..b6e1b7a 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -10,7 +10,6 @@ from testtools.compat import ( from testtools.content import ( Content, TracebackContent, - stream_content, text_content, ) from testtools.content_type import ( @@ -81,6 +80,22 @@ class TestContent(TestCase): content = Content(content_type, lambda: [iso_version]) self.assertEqual([text], list(content.iter_text())) + 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_text(self): + data = _u("some data") + expected = Content(UTF8_TEXT, lambda: [data.encode('utf8')]) + self.assertEqual(expected, Content.from_text(data)) + class TestTracebackContent(TestCase): @@ -106,20 +121,6 @@ class TestBytesContent(TestCase): self.assertEqual(expected, text_content(data)) -class TestStreamContent(TestCase): - - def test_stream(self): - data = StringIO('some data') - content = stream_content(data, UTF8_TEXT, chunk_size=2) - self.assertThat( - list(content.iter_bytes()), Equals(['so', 'me', ' d', 'at', 'a'])) - - def test_default_type(self): - data = StringIO('some data') - content = stream_content(data) - self.assertThat(content.content_type, Equals(UTF8_TEXT)) - - def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) From 74382d0158bdbbea2ddebcfd774883dd5075280e Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 19:02:04 +0000 Subject: [PATCH 06/22] Basic convenience method for reading content from a file. --- testtools/content.py | 22 ++++++++++++++++++++++ testtools/tests/test_content.py | 16 ++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/testtools/content.py b/testtools/content.py index 4038a70..5966536 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -39,6 +39,28 @@ class Content(object): 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=DEFAULT_CHUNK_SIZE): + """Create a `Content` object from a file on disk. + + :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`. + """ + if content_type is None: + content_type = UTF8_TEXT + def read_file(): + stream = open(path, 'rb') + chunk = stream.read(chunk_size) + while chunk: + yield chunk + chunk = stream.read(chunk_size) + stream.close() + return cls(content_type, read_file) + @classmethod def from_stream(cls, stream, content_type=None, chunk_size=DEFAULT_CHUNK_SIZE): diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index b6e1b7a..58e8693 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -1,6 +1,9 @@ # 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, @@ -80,6 +83,19 @@ class TestContent(TestCase): 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_file_default_type(self): + content = Content.from_file('/nonexistent/path') + self.assertThat(content.content_type, Equals(UTF8_TEXT)) + def test_from_stream(self): data = StringIO('some data') content = Content.from_stream(data, UTF8_TEXT, chunk_size=2) From 3b6ace4a7459630deaba1e00518a41c9bfff5dbe Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 19:06:12 +0000 Subject: [PATCH 07/22] Remove duplication. --- testtools/content.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index 5966536..14d4858 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -15,6 +15,18 @@ _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. @@ -54,10 +66,8 @@ class Content(object): content_type = UTF8_TEXT def read_file(): stream = open(path, 'rb') - chunk = stream.read(chunk_size) - while chunk: + for chunk in _iter_chunks(stream, chunk_size): yield chunk - chunk = stream.read(chunk_size) stream.close() return cls(content_type, read_file) @@ -74,12 +84,7 @@ class Content(object): """ if content_type is None: content_type = UTF8_TEXT - def read_chunk(): - chunk = stream.read(chunk_size) - while chunk: - yield chunk - chunk = stream.read(chunk_size) - return Content(content_type, read_chunk) + return Content(content_type, lambda: _iter_chunks(stream, chunk_size)) @classmethod def from_text(cls, text): From ff6e7db28d85e87ab3767359e06292c6bd2aeef6 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 19:08:19 +0000 Subject: [PATCH 08/22] Update NEWS and MANUAL. --- NEWS | 4 ++-- doc/for-test-authors.rst | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/NEWS b/NEWS index a4b531f..846b247 100644 --- a/NEWS +++ b/NEWS @@ -21,8 +21,8 @@ Improvements * ``MultiTestResult`` now documented in the manual. (Jonathan Lange, #661116) -* New content helper ``Content.from_stream`` makes it easier to attach - file-like objects to a test. (Jonathan Lange, #694126) +* New content helpers ``Content.from_file`` and ``Content.from_stream`` make + it easier to attach file-like objects to a test. (Jonathan Lange, #694126) * Vastly improved and extended documentation. (Jonathan Lange) diff --git a/doc/for-test-authors.rst b/doc/for-test-authors.rst index 6e5d3b2..45d4533 100644 --- a/doc/for-test-authors.rst +++ b/doc/for-test-authors.rst @@ -634,12 +634,16 @@ do:: 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 From 2967bbc0b7a09d21a4edbfeabefd31d9d666c43b Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 19:30:17 +0000 Subject: [PATCH 09/22] attachFile helper. --- NEWS | 5 +++-- testtools/content.py | 10 ++++++---- testtools/testcase.py | 5 +++++ testtools/tests/test_testtools.py | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/NEWS b/NEWS index 846b247..d133e58 100644 --- a/NEWS +++ b/NEWS @@ -21,8 +21,9 @@ Improvements * ``MultiTestResult`` now documented in the manual. (Jonathan Lange, #661116) -* New content helpers ``Content.from_file`` and ``Content.from_stream`` make - it easier to attach file-like objects to a test. (Jonathan Lange, #694126) +* 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) diff --git a/testtools/content.py b/testtools/content.py index 14d4858..404dcc3 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -52,8 +52,7 @@ class Content(object): _join_b(self.iter_bytes()) == _join_b(other.iter_bytes())) @classmethod - def from_file(cls, path, content_type=None, - chunk_size=DEFAULT_CHUNK_SIZE): + def from_file(cls, path, content_type=None, chunk_size=None): """Create a `Content` object from a file on disk. :param path: The path to the file to be used as content. @@ -64,6 +63,8 @@ class Content(object): """ if content_type is None: content_type = UTF8_TEXT + if chunk_size is None: + chunk_size = DEFAULT_CHUNK_SIZE def read_file(): stream = open(path, 'rb') for chunk in _iter_chunks(stream, chunk_size): @@ -72,8 +73,7 @@ class Content(object): return cls(content_type, read_file) @classmethod - def from_stream(cls, stream, content_type=None, - chunk_size=DEFAULT_CHUNK_SIZE): + def from_stream(cls, stream, content_type=None, chunk_size=None): """Create a `Content` object from a file-like stream. :param stream: A file-like object to read the content from. @@ -84,6 +84,8 @@ class Content(object): """ if content_type is None: content_type = UTF8_TEXT + if chunk_size is None: + chunk_size = DEFAULT_CHUNK_SIZE return Content(content_type, lambda: _iter_chunks(stream, chunk_size)) @classmethod diff --git a/testtools/testcase.py b/testtools/testcase.py index 804684a..7666a57 100644 --- a/testtools/testcase.py +++ b/testtools/testcase.py @@ -167,6 +167,11 @@ class TestCase(unittest.TestCase): self.__details = {} self.__details[name] = content_object + def attachFile(self, name, path, content_type=None, chunk_size=None): + 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. diff --git a/testtools/tests/test_testtools.py b/testtools/tests/test_testtools.py index 2e722e9..91be136 100644 --- a/testtools/tests/test_testtools.py +++ b/testtools/tests/test_testtools.py @@ -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 @@ class TestDetailsProvided(TestWithDetails): 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): From 26c0d8829ecd92e3767c2fce654956a155fd42ff Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 19:38:35 +0000 Subject: [PATCH 10/22] More documentation. --- testtools/content.py | 6 ++++++ testtools/testcase.py | 14 ++++++++++++++ testtools/tests/test_content.py | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/testtools/content.py b/testtools/content.py index 404dcc3..252a205 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -55,6 +55,9 @@ class Content(object): def from_file(cls, path, content_type=None, chunk_size=None): """Create a `Content` object from a file on disk. + Note that 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. @@ -76,6 +79,9 @@ class Content(object): def from_stream(cls, stream, content_type=None, chunk_size=None): """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. diff --git a/testtools/testcase.py b/testtools/testcase.py index 7666a57..28d3024 100644 --- a/testtools/testcase.py +++ b/testtools/testcase.py @@ -168,6 +168,20 @@ class TestCase(unittest.TestCase): self.__details[name] = content_object def attachFile(self, name, path, content_type=None, chunk_size=None): + """Attach a file to this test as a detail. + + This is a convenience method wrapping around `addDetail`. + + Note that 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. + """ content_object = content.Content.from_file( path, content_type, chunk_size) self.addDetail(name, content_object) diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 58e8693..390d645 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -23,6 +23,7 @@ from testtools.matchers import ( Equals, MatchesException, Raises, + raises, ) from testtools.tests.helpers import an_exc_info @@ -92,6 +93,12 @@ class TestContent(TestCase): 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)) From d9489d7eb7808a90fa6a4c259311e3645641064e Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 19:54:55 +0000 Subject: [PATCH 11/22] Add eager loading options to from_file and from_stream --- testtools/content.py | 27 +++++++++++++++++++-------- testtools/tests/test_content.py | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index 252a205..1191408 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -52,31 +52,38 @@ class Content(object): _join_b(self.iter_bytes()) == _join_b(other.iter_bytes())) @classmethod - def from_file(cls, path, content_type=None, chunk_size=None): + 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 the file will only be read from when ``iter_bytes`` is - called. + 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`. + 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 read_file(): + def reader(): stream = open(path, 'rb') for chunk in _iter_chunks(stream, chunk_size): yield chunk stream.close() - return cls(content_type, read_file) + 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): + 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 @@ -92,7 +99,11 @@ class Content(object): content_type = UTF8_TEXT if chunk_size is None: chunk_size = DEFAULT_CHUNK_SIZE - return Content(content_type, lambda: _iter_chunks(stream, 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): diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 390d645..7601302 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -103,6 +103,15 @@ class TestContent(TestCase): 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) @@ -114,6 +123,17 @@ class TestContent(TestCase): 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')]) From 2c20ccfb4490c0e59ccedca73d5d3b42c44f4deb Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Wed, 29 Dec 2010 19:56:15 +0000 Subject: [PATCH 12/22] Re-export the eager loading convenience. --- testtools/testcase.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/testtools/testcase.py b/testtools/testcase.py index 28d3024..24d388a 100644 --- a/testtools/testcase.py +++ b/testtools/testcase.py @@ -167,13 +167,15 @@ class TestCase(unittest.TestCase): self.__details = {} self.__details[name] = content_object - def attachFile(self, name, path, content_type=None, chunk_size=None): + 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 the file *must* exist when the test result is called with - the results of this test, after the test has been torn down. + 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. @@ -181,6 +183,8 @@ class TestCase(unittest.TestCase): 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) From 949c59c80c4e1347eda469c570cd8b3f28b27de7 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 17:31:27 +0000 Subject: [PATCH 13/22] Copyright bump --- testtools/content.py | 2 +- testtools/tests/test_content.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index 13b678d..1702595 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -1,4 +1,4 @@ -# Copyright (c) 2009-2010 testtools developers. See LICENSE for details. +# Copyright (c) 2009-2011 testtools developers. See LICENSE for details. """Content - a MIME-like Content object.""" diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 8936555..35ee9dd 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -1,4 +1,4 @@ -# Copyright (c) 2008-2010 testtools developers. See LICENSE for details. +# Copyright (c) 2008-2011 testtools developers. See LICENSE for details. import os import tempfile From 047910415fab3010c6c3e24181e489779130f316 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 17:36:43 +0000 Subject: [PATCH 14/22] Delete from_text. --- testtools/content.py | 17 ++++++++--------- testtools/tests/test_content.py | 10 +--------- testtools/tests/test_testtools.py | 2 +- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index 1702595..abccf34 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -2,6 +2,12 @@ """Content - a MIME-like Content object.""" +__all__ = [ + 'Content', + 'text_content', + 'TracebackContent', + ] + import codecs from testtools.compat import _b @@ -105,14 +111,6 @@ class Content(object): 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() @@ -176,4 +174,5 @@ def text_content(text): This is useful for adding details which are short strings. """ - return Content.from_text(text) + return Content(UTF8_TEXT, lambda: [text.encode('utf8')]) + diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 35ee9dd..727e1e1 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -137,7 +137,7 @@ class TestContent(TestCase): def test_from_text(self): data = _u("some data") expected = Content(UTF8_TEXT, lambda: [data.encode('utf8')]) - self.assertEqual(expected, Content.from_text(data)) + self.assertEqual(expected, text_content(data)) class TestTracebackContent(TestCase): @@ -156,14 +156,6 @@ class TestTracebackContent(TestCase): self.assertEqual(expected, ''.join(list(content.iter_text()))) -class TestBytesContent(TestCase): - - def test_bytes(self): - data = _u("some data") - expected = Content(UTF8_TEXT, lambda: [data.encode('utf8')]) - self.assertEqual(expected, text_content(data)) - - def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/testtools/tests/test_testtools.py b/testtools/tests/test_testtools.py index e4a61f0..62ef8af 100644 --- a/testtools/tests/test_testtools.py +++ b/testtools/tests/test_testtools.py @@ -835,7 +835,7 @@ class TestDetailsProvided(TestWithDetails): self.addCleanup(os.remove, path) os.write(fd, 'some data') os.close(fd) - my_content = content.Content.from_text('some data') + my_content = content.text_content('some data') test.attachFile('foo', path) self.assertEqual({'foo': my_content}, test.getDetails()) From 0876777983109a4a1e312047305df572f21b1b73 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 17:40:41 +0000 Subject: [PATCH 15/22] Move Content.from_stream to be content_from_stream --- testtools/content.py | 109 ++++++++++++++++---------------- testtools/testcase.py | 2 +- testtools/tests/test_content.py | 16 +++-- 3 files changed, 65 insertions(+), 62 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index abccf34..e1b0853 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -4,6 +4,8 @@ __all__ = [ 'Content', + 'content_from_file', + 'content_from_stream', 'text_content', 'TracebackContent', ] @@ -57,60 +59,6 @@ class Content(object): 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) - def iter_bytes(self): """Iterate over bytestrings of the serialised content.""" return self._get_bytes() @@ -176,3 +124,56 @@ def text_content(text): """ return Content(UTF8_TEXT, lambda: [text.encode('utf8')]) + +def content_from_file(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 Content(content_type, reader) + + +def content_from_stream(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 Content(content_type, reader) diff --git a/testtools/testcase.py b/testtools/testcase.py index 58638ec..5bbf1fe 100644 --- a/testtools/testcase.py +++ b/testtools/testcase.py @@ -195,7 +195,7 @@ class TestCase(unittest.TestCase): :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( + content_object = content.content_from_file( path, content_type, chunk_size) self.addDetail(name, content_object) diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 727e1e1..375ec6a 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -12,6 +12,8 @@ from testtools.compat import ( ) from testtools.content import ( Content, + content_from_file, + content_from_stream, TracebackContent, text_content, ) @@ -89,38 +91,38 @@ class TestContent(TestCase): self.addCleanup(os.remove, path) os.write(fd, 'some data') os.close(fd) - content = Content.from_file(path, UTF8_TEXT, chunk_size=2) + 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) + 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') + 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) + 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) + 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) + content = content_from_stream(data) self.assertThat(content.content_type, Equals(UTF8_TEXT)) def test_from_stream_eager_loading(self): @@ -128,7 +130,7 @@ class TestContent(TestCase): self.addCleanup(os.remove, path) os.write(fd, 'some data') stream = open(path, 'rb') - content = Content.from_stream(stream, UTF8_TEXT, read_now=True) + content = content_from_stream(stream, UTF8_TEXT, read_now=True) os.write(fd, 'more data') os.close(fd) self.assertThat( From 4802ca1d50d74fff068d9a0de957dcd173bb2f7a Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 17:41:29 +0000 Subject: [PATCH 16/22] Fix up the documentation. --- doc/for-test-authors.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/for-test-authors.rst b/doc/for-test-authors.rst index d23a7b5..21ac656 100644 --- a/doc/for-test-authors.rst +++ b/doc/for-test-authors.rst @@ -737,9 +737,9 @@ 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:: +Or you could use the convenience function:: - image = Content.from_file('foo.png', ContentType('image', 'png')) + 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 From 721e7010f76bd4adad930f3a372b859f4e875906 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 18:27:14 +0000 Subject: [PATCH 17/22] Move attachFile from TestCase to be a standalone function. --- NEWS | 4 ++-- testtools/content.py | 26 ++++++++++++++++++++++++++ testtools/testcase.py | 23 ----------------------- testtools/tests/test_content.py | 17 +++++++++++++++++ testtools/tests/test_testtools.py | 13 ------------- 5 files changed, 45 insertions(+), 38 deletions(-) diff --git a/NEWS b/NEWS index 68dbdcb..9a14408 100644 --- a/NEWS +++ b/NEWS @@ -24,8 +24,8 @@ Improvements * ``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 +* New content helpers ``content_from_file``, ``content_from_stream`` and + ``attach_file`` make it easier to attach file-like objects to a test. (Jonathan Lange, #694126) * New ``ExpectedException`` context manager to help write tests against things diff --git a/testtools/content.py b/testtools/content.py index e1b0853..b766b45 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -3,6 +3,7 @@ """Content - a MIME-like Content object.""" __all__ = [ + 'attach_file', 'Content', 'content_from_file', 'content_from_stream', @@ -177,3 +178,28 @@ def content_from_stream(stream, content_type=None, chunk_size=None, contents = list(reader()) reader = lambda: contents return Content(content_type, reader) + + +def attach_file(detailed, 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 detailed: An object with details + :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_from_file(path, content_type, chunk_size) + detailed.addDetail(name, content_object) + diff --git a/testtools/testcase.py b/testtools/testcase.py index 5bbf1fe..f557726 100644 --- a/testtools/testcase.py +++ b/testtools/testcase.py @@ -176,29 +176,6 @@ class TestCase(unittest.TestCase): 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. diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 375ec6a..41044a4 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -11,6 +11,7 @@ from testtools.compat import ( StringIO, ) from testtools.content import ( + attach_file, Content, content_from_file, content_from_stream, @@ -158,6 +159,22 @@ class TestTracebackContent(TestCase): self.assertEqual(expected, ''.join(list(content.iter_text()))) +class TestAttachFile(TestCase): + + def test_simple(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 = text_content('some data') + attach_file(test, 'foo', path) + self.assertEqual({'foo': my_content}, test.getDetails()) + + def test_suite(): from unittest import TestLoader return TestLoader().loadTestsFromName(__name__) diff --git a/testtools/tests/test_testtools.py b/testtools/tests/test_testtools.py index 62ef8af..208eb36 100644 --- a/testtools/tests/test_testtools.py +++ b/testtools/tests/test_testtools.py @@ -826,19 +826,6 @@ class TestDetailsProvided(TestWithDetails): 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.text_content('some data') - test.attachFile('foo', path) - self.assertEqual({'foo': my_content}, test.getDetails()) - def test_addError(self): class Case(TestCase): def test(this): From 434247c283b259b60b2b169dbb3cbaed2c25df4e Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 18:30:58 +0000 Subject: [PATCH 18/22] Change the order of parameters to attach_file --- testtools/content.py | 2 +- testtools/tests/test_content.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index b766b45..0c4dc4c 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -180,7 +180,7 @@ def content_from_stream(stream, content_type=None, chunk_size=None, return Content(content_type, reader) -def attach_file(detailed, name, path, content_type=None, chunk_size=None, +def attach_file(detailed, path, name,content_type=None, chunk_size=None, read_now=False): """Attach a file to this test as a detail. diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 41044a4..7733776 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -171,7 +171,7 @@ class TestAttachFile(TestCase): os.write(fd, 'some data') os.close(fd) my_content = text_content('some data') - attach_file(test, 'foo', path) + attach_file(test, path, name='foo') self.assertEqual({'foo': my_content}, test.getDetails()) From f8bffa7c89971bc817367f6cd5426587c4167596 Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 18:35:30 +0000 Subject: [PATCH 19/22] read_now => lazy_read, as per review. --- testtools/content.py | 29 ++++++++++++++++++----------- testtools/tests/test_content.py | 4 ++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index 0c4dc4c..011952b 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -127,7 +127,7 @@ def text_content(text): def content_from_file(path, content_type=None, chunk_size=None, - read_now=False): + lazy_read=True): """Create a `Content` object from a file on disk. Note that unless 'read_now' is explicitly passed in as True, the file @@ -138,8 +138,8 @@ def content_from_file(path, content_type=None, chunk_size=None, 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. + :param lazy_read: If False, read the file from disk now and keep it in + memory. Otherwise, only read when the content is serialized. """ if content_type is None: content_type = UTF8_TEXT @@ -150,14 +150,14 @@ def content_from_file(path, content_type=None, chunk_size=None, for chunk in _iter_chunks(stream, chunk_size): yield chunk stream.close() - if read_now: + if not lazy_read: contents = list(reader()) reader = lambda: contents return Content(content_type, reader) def content_from_stream(stream, content_type=None, chunk_size=None, - read_now=False): + lazy_read=True): """Create a `Content` object from a file-like stream. Note that the stream will only be read from when ``iter_bytes`` is @@ -167,21 +167,22 @@ def content_from_stream(stream, content_type=None, chunk_size=None, :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`. + Defaults to `DEFAULT_CHUNK_SIZE`. + :param lazy_read: If False, reads from the stream right now. Otherwise, + only reads when the content is serialized. Defaults to True. """ 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: + if not lazy_read: contents = list(reader()) reader = lambda: contents return Content(content_type, reader) -def attach_file(detailed, path, name,content_type=None, chunk_size=None, - read_now=False): +def attach_file(detailed, path, name,content_type=None, chunk_size=None): """Attach a file to this test as a detail. This is a convenience method wrapping around `addDetail`. @@ -197,8 +198,14 @@ def attach_file(detailed, path, name,content_type=None, chunk_size=None, 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. + :param lazy_read: If True the file content is not read when attach_file is + called, but later when the content object is evaluated. Note that this + may be after any cleanups that obj_with_details has, so if the file is + a temporary file lazy_read may cause the file to be read after it is + deleted. To handle those cases, using attach_file as a cleanup is + recommended:: + + obj_with_details.addCleanUp(attach_file, 'foo.txt', obj_with_details) """ content_object = content_from_file(path, content_type, chunk_size) detailed.addDetail(name, content_object) diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 7733776..9b1f64e 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -110,7 +110,7 @@ class TestContent(TestCase): fd, path = tempfile.mkstemp() os.write(fd, 'some data') os.close(fd) - content = content_from_file(path, UTF8_TEXT, read_now=True) + content = content_from_file(path, UTF8_TEXT, lazy_read=False) os.remove(path) self.assertThat( _b('').join(content.iter_bytes()), Equals('some data')) @@ -131,7 +131,7 @@ class TestContent(TestCase): self.addCleanup(os.remove, path) os.write(fd, 'some data') stream = open(path, 'rb') - content = content_from_stream(stream, UTF8_TEXT, read_now=True) + content = content_from_stream(stream, UTF8_TEXT, lazy_read=False) os.write(fd, 'more data') os.close(fd) self.assertThat( From 42b45390d0c185d6e8d15ba59978ca4a811bfe9f Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Tue, 1 Feb 2011 18:56:29 +0000 Subject: [PATCH 20/22] More tests for attach_file, make lazy read work. --- doc/for-test-authors.rst | 10 ++----- testtools/content.py | 14 +++++---- testtools/tests/test_content.py | 53 +++++++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/doc/for-test-authors.rst b/doc/for-test-authors.rst index 21ac656..54b7980 100644 --- a/doc/for-test-authors.rst +++ b/doc/for-test-authors.rst @@ -731,7 +731,7 @@ do:: Because adding small bits of text content is very common, there's also a convenience method:: - text = Content.from_text("some text") + text = text_content("some text") To make content out of an image stored on disk, you could do something like:: @@ -758,7 +758,7 @@ see the client-side error *and* the logs from the server-side. Here's how you might do it:: from testtools import TestCase - from testtools.content import Content + from testtools.content import attach_file, Content from testtools.content_type import UTF8_TEXT from myproject import SomeServer @@ -770,7 +770,7 @@ might do it:: self.server = SomeServer() self.server.start_up() self.addCleanup(self.server.shut_down) - self.addCleanup(self.attach_log_file) + self.addCleanup(attach_file, self.server.logfile, self) def attach_log_file(self): self.addDetail( @@ -785,10 +785,6 @@ This test will attach the log file of ``SomeServer`` to each test that is run. testtools will only display the log file for failing tests, so it's not such a big deal. -Note the callable passed to ``Content`` reads the log file line-by-line. This -is something of an optimization. If we naively returned all of the log file -as one bytestring, ``Content`` would treat that as a list of byte chunks. - If the act of adding at detail is expensive, you might want to use addOnException_ so that you only do it when a test actually raises an exception. diff --git a/testtools/content.py b/testtools/content.py index 011952b..867e227 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -12,6 +12,7 @@ __all__ = [ ] import codecs +import os from testtools.compat import _b from testtools.content_type import ContentType, UTF8_TEXT @@ -182,7 +183,8 @@ def content_from_stream(stream, content_type=None, chunk_size=None, return Content(content_type, reader) -def attach_file(detailed, path, name,content_type=None, chunk_size=None): +def attach_file(detailed, path, name=None, content_type=None, + chunk_size=None, lazy_read=False): """Attach a file to this test as a detail. This is a convenience method wrapping around `addDetail`. @@ -192,8 +194,8 @@ def attach_file(detailed, path, name,content_type=None, chunk_size=None): test, after the test has been torn down. :param detailed: An object with details - :param name: The name to give to the detail for the attached file. :param path: The path to the file to attach. + :param name: The name to give to the detail for the attached file. :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 @@ -205,8 +207,10 @@ def attach_file(detailed, path, name,content_type=None, chunk_size=None): deleted. To handle those cases, using attach_file as a cleanup is recommended:: - obj_with_details.addCleanUp(attach_file, 'foo.txt', obj_with_details) + detailed.addCleanup(attach_file, 'foo.txt', detailed) """ - content_object = content_from_file(path, content_type, chunk_size) + if name is None: + name = os.path.basename(os.path.abspath(path)) + content_object = content_from_file( + path, content_type, chunk_size, lazy_read) detailed.addDetail(name, content_object) - diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 9b1f64e..218594b 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -161,19 +161,62 @@ class TestTracebackContent(TestCase): class TestAttachFile(TestCase): + def make_file(self, data): + fd, path = tempfile.mkstemp() + self.addCleanup(os.remove, path) + os.write(fd, data) + os.close(fd) + return path + def test_simple(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 = text_content('some data') + data = 'some data' + path = self.make_file(data) + my_content = text_content(data) attach_file(test, path, name='foo') self.assertEqual({'foo': my_content}, test.getDetails()) + def test_optional_name(self): + # If no name is provided, attach_file just uses the base name of the + # file. + class SomeTest(TestCase): + def test_foo(self): + pass + test = SomeTest('test_foo') + path = self.make_file('some data') + base_path = os.path.basename(path) + attach_file(test, path) + self.assertEqual([base_path], list(test.getDetails())) + + def test_lazy_read(self): + class SomeTest(TestCase): + def test_foo(self): + pass + test = SomeTest('test_foo') + path = self.make_file('some data') + attach_file(test, path, name='foo', lazy_read=True) + content = test.getDetails()['foo'] + content_file = open(path, 'w') + content_file.write('new data') + content_file.close() + self.assertEqual(''.join(content.iter_bytes()), 'new data') + + def test_eager_read_by_default(self): + class SomeTest(TestCase): + def test_foo(self): + pass + test = SomeTest('test_foo') + path = self.make_file('some data') + attach_file(test, path, name='foo') + content = test.getDetails()['foo'] + content_file = open(path, 'w') + content_file.write('new data') + content_file.close() + self.assertEqual(''.join(content.iter_bytes()), 'some data') + def test_suite(): from unittest import TestLoader From 8a48f927c4194ada7df493163ce8a9060ec6afa2 Mon Sep 17 00:00:00 2001 From: Robert Collins Date: Sat, 19 Feb 2011 21:54:14 +1300 Subject: [PATCH 21/22] Tweaks for Jono's consideration. --- testtools/content.py | 100 +++++++++++++++++++++++--------- testtools/tests/test_content.py | 6 +- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/testtools/content.py b/testtools/content.py index 867e227..0a2997b 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -14,10 +14,12 @@ __all__ = [ import codecs import os +from testtools import try_import from testtools.compat import _b from testtools.content_type import ContentType, UTF8_TEXT from testtools.testresult import TestResult +functools = try_import('functools') _join_b = _b("").join @@ -127,8 +129,42 @@ def text_content(text): return Content(UTF8_TEXT, lambda: [text.encode('utf8')]) + +def maybe_wrap(wrapper, func): + """Merge metadata for func into wrapper if functools is present.""" + if functools is not None: + wrapper = functools.update_wrapper(wrapper, func) + return wrapper + + +def _default_parameter(offset, name, default): + """Create a decorator which will default a parameter to a value. + + :param offset: the offset if the parameter is supplied in *args. + e.g. in 'def foo(bar, quux)' the offset of quux is 2. + :param name: The key for the parameter if supplied in **kwargs. + :param default: The default value to use for the parameter. + """ + def decorator(func): + def wrapper(*args, **kwargs): + if len(args) < offset: + if kwargs.get(name, None) is None: + kwargs[name] = default + elif args[offset-1] is None: + args = args[:offset-1] + (default,) + args[offset:] + return func(*args, **kwargs) + return maybe_wrap(wrapper, func) + return decorator + + +_set_content_type = _default_parameter(2, 'content_type', UTF8_TEXT) +_set_chunk_size = _default_parameter(3, 'chunk_size', DEFAULT_CHUNK_SIZE) + + +@_set_content_type +@_set_chunk_size def content_from_file(path, content_type=None, chunk_size=None, - lazy_read=True): + buffer_now=False): """Create a `Content` object from a file on disk. Note that unless 'read_now' is explicitly passed in as True, the file @@ -139,52 +175,59 @@ def content_from_file(path, content_type=None, chunk_size=None, to UTF8-encoded text/plain. :param chunk_size: The size of chunks to read from the file. Defaults to `DEFAULT_CHUNK_SIZE`. - :param lazy_read: If False, read the file from disk now and keep it in + :param buffer_now: If True, read the file from disk now and keep it in memory. Otherwise, only read when the content is serialized. """ - if content_type is None: - content_type = UTF8_TEXT - if chunk_size is None: - chunk_size = DEFAULT_CHUNK_SIZE def reader(): + # This should be try:finally:, but python2.4 makes that hard. When + # We drop older python support we can make this use a context manager + # for maximum simplicity. stream = open(path, 'rb') for chunk in _iter_chunks(stream, chunk_size): yield chunk stream.close() - if not lazy_read: - contents = list(reader()) - reader = lambda: contents - return Content(content_type, reader) + return content_from_reader(reader, content_type, buffer_now) +@_set_content_type +@_set_chunk_size def content_from_stream(stream, content_type=None, chunk_size=None, - lazy_read=True): + buffer_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 stream: A file-like object to read the content from. The stream + is not closed by this function or the content object it returns. :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 lazy_read: If False, reads from the stream right now. Otherwise, - only reads when the content is serialized. Defaults to True. + :param buffer_now: If True, reads from the stream right now. Otherwise, + only reads when the content is serialized. Defaults to False. """ - 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 not lazy_read: + return content_from_reader(reader, content_type, buffer_now) + + +def content_from_reader(reader, content_type, buffer_now): + """Create a Content object that will obtain the content from reader. + + :param reader: A callback to read the content. Should return an iterable of + bytestrings. + :param content_type: The content type to create. + :param buffer_now: If True the reader is evaluated immediately and + buffered. + """ + if buffer_now: contents = list(reader()) reader = lambda: contents return Content(content_type, reader) def attach_file(detailed, path, name=None, content_type=None, - chunk_size=None, lazy_read=False): + chunk_size=None, buffer_now=True): """Attach a file to this test as a detail. This is a convenience method wrapping around `addDetail`. @@ -200,17 +243,18 @@ def attach_file(detailed, path, name=None, content_type=None, defaults to UTF8-encoded text/plain. :param chunk_size: The size of chunks to read from the file. Defaults to something sensible. - :param lazy_read: If True the file content is not read when attach_file is - called, but later when the content object is evaluated. Note that this - may be after any cleanups that obj_with_details has, so if the file is - a temporary file lazy_read may cause the file to be read after it is - deleted. To handle those cases, using attach_file as a cleanup is - recommended:: + :param buffer_now: If False the file content is read when the content + object is evaluated rather than when attach_file is called. + Note that this may be after any cleanups that obj_with_details has, so + if the file is a temporary file disabling buffer_now may cause the file + to be read after it is deleted. To handle those cases, using + attach_file as a cleanup is recommended because it guarantees a + sequence for when the attach_file call is made:: detailed.addCleanup(attach_file, 'foo.txt', detailed) """ if name is None: - name = os.path.basename(os.path.abspath(path)) + name = os.path.basename(path) content_object = content_from_file( - path, content_type, chunk_size, lazy_read) + path, content_type, chunk_size, buffer_now) detailed.addDetail(name, content_object) diff --git a/testtools/tests/test_content.py b/testtools/tests/test_content.py index 218594b..07c15af 100644 --- a/testtools/tests/test_content.py +++ b/testtools/tests/test_content.py @@ -110,7 +110,7 @@ class TestContent(TestCase): fd, path = tempfile.mkstemp() os.write(fd, 'some data') os.close(fd) - content = content_from_file(path, UTF8_TEXT, lazy_read=False) + content = content_from_file(path, UTF8_TEXT, buffer_now=True) os.remove(path) self.assertThat( _b('').join(content.iter_bytes()), Equals('some data')) @@ -131,7 +131,7 @@ class TestContent(TestCase): self.addCleanup(os.remove, path) os.write(fd, 'some data') stream = open(path, 'rb') - content = content_from_stream(stream, UTF8_TEXT, lazy_read=False) + content = content_from_stream(stream, UTF8_TEXT, buffer_now=True) os.write(fd, 'more data') os.close(fd) self.assertThat( @@ -197,7 +197,7 @@ class TestAttachFile(TestCase): pass test = SomeTest('test_foo') path = self.make_file('some data') - attach_file(test, path, name='foo', lazy_read=True) + attach_file(test, path, name='foo', buffer_now=False) content = test.getDetails()['foo'] content_file = open(path, 'w') content_file.write('new data') From 2d1f8e189d401b69c2694c3d82cc9661973c8ebe Mon Sep 17 00:00:00 2001 From: Jonathan Lange Date: Fri, 1 Apr 2011 14:45:22 +0100 Subject: [PATCH 22/22] Remove the decorators, correctly credit lifeless in the news file. --- NEWS | 2 +- testtools/content.py | 42 ++++++++++-------------------------------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/NEWS b/NEWS index 7afce86..3b98d8f 100644 --- a/NEWS +++ b/NEWS @@ -29,7 +29,7 @@ Improvements * New content helpers ``content_from_file``, ``content_from_stream`` and ``attach_file`` make it easier to attach file-like objects to a - test. (Jonathan Lange, #694126) + test. (Jonathan Lange, Robert Collins, #694126) * New ``ExpectedException`` context manager to help write tests against things that are expected to raise exceptions. (Aaron Bentley) diff --git a/testtools/content.py b/testtools/content.py index 0a2997b..2c6ed9f 100644 --- a/testtools/content.py +++ b/testtools/content.py @@ -137,33 +137,7 @@ def maybe_wrap(wrapper, func): return wrapper -def _default_parameter(offset, name, default): - """Create a decorator which will default a parameter to a value. - - :param offset: the offset if the parameter is supplied in *args. - e.g. in 'def foo(bar, quux)' the offset of quux is 2. - :param name: The key for the parameter if supplied in **kwargs. - :param default: The default value to use for the parameter. - """ - def decorator(func): - def wrapper(*args, **kwargs): - if len(args) < offset: - if kwargs.get(name, None) is None: - kwargs[name] = default - elif args[offset-1] is None: - args = args[:offset-1] + (default,) + args[offset:] - return func(*args, **kwargs) - return maybe_wrap(wrapper, func) - return decorator - - -_set_content_type = _default_parameter(2, 'content_type', UTF8_TEXT) -_set_chunk_size = _default_parameter(3, 'chunk_size', DEFAULT_CHUNK_SIZE) - - -@_set_content_type -@_set_chunk_size -def content_from_file(path, content_type=None, chunk_size=None, +def content_from_file(path, content_type=None, chunk_size=DEFAULT_CHUNK_SIZE, buffer_now=False): """Create a `Content` object from a file on disk. @@ -178,6 +152,8 @@ def content_from_file(path, content_type=None, chunk_size=None, :param buffer_now: If True, read the file from disk now and keep it in memory. Otherwise, only read when the content is serialized. """ + if content_type is None: + content_type = UTF8_TEXT def reader(): # This should be try:finally:, but python2.4 makes that hard. When # We drop older python support we can make this use a context manager @@ -189,10 +165,8 @@ def content_from_file(path, content_type=None, chunk_size=None, return content_from_reader(reader, content_type, buffer_now) -@_set_content_type -@_set_chunk_size -def content_from_stream(stream, content_type=None, chunk_size=None, - buffer_now=False): +def content_from_stream(stream, content_type=None, + chunk_size=DEFAULT_CHUNK_SIZE, buffer_now=False): """Create a `Content` object from a file-like stream. Note that the stream will only be read from when ``iter_bytes`` is @@ -207,6 +181,8 @@ def content_from_stream(stream, content_type=None, chunk_size=None, :param buffer_now: If True, reads from the stream right now. Otherwise, only reads when the content is serialized. Defaults to False. """ + if content_type is None: + content_type = UTF8_TEXT reader = lambda: _iter_chunks(stream, chunk_size) return content_from_reader(reader, content_type, buffer_now) @@ -220,6 +196,8 @@ def content_from_reader(reader, content_type, buffer_now): :param buffer_now: If True the reader is evaluated immediately and buffered. """ + if content_type is None: + content_type = UTF8_TEXT if buffer_now: contents = list(reader()) reader = lambda: contents @@ -227,7 +205,7 @@ def content_from_reader(reader, content_type, buffer_now): def attach_file(detailed, path, name=None, content_type=None, - chunk_size=None, buffer_now=True): + chunk_size=DEFAULT_CHUNK_SIZE, buffer_now=True): """Attach a file to this test as a detail. This is a convenience method wrapping around `addDetail`.