diff options
author | Robert Collins <robertc@robertcollins.net> | 2012-10-27 22:27:55 +1300 |
---|---|---|
committer | Robert Collins <robertc@robertcollins.net> | 2012-10-27 22:27:55 +1300 |
commit | f8e93227207a1eddef02cb306651074172f64188 (patch) | |
tree | 1130670ee05bf24efccc0ce81f8bba6bb8fb5052 | |
parent | 87a05a447f982e4ddc954fb2c72fa59637c4bd58 (diff) | |
download | python-test-extras-f8e93227207a1eddef02cb306651074172f64188.tar.gz |
Initial extraction of code from testtools.
-rw-r--r-- | .gitignore | 7 | ||||
-rw-r--r-- | .testr.conf | 4 | ||||
-rw-r--r-- | LICENSE | 26 | ||||
-rw-r--r-- | MANIFEST.in | 6 | ||||
-rw-r--r-- | Makefile | 56 | ||||
-rw-r--r-- | NEWS | 15 | ||||
-rw-r--r-- | README.rst | 49 | ||||
-rw-r--r-- | extras/__init__.py | 105 | ||||
-rw-r--r-- | extras/tests/__init__.py | 17 | ||||
-rw-r--r-- | extras/tests/test_extras.py | 188 | ||||
-rw-r--r-- | setup.cfg | 4 | ||||
-rwxr-xr-x | setup.py | 36 |
12 files changed, 513 insertions, 0 deletions
@@ -25,3 +25,10 @@ pip-log.txt #Mr Developer .mr.developer.cfg + +# editors +*.swp +*~ + +# Testrepository +.testrepository diff --git a/.testr.conf b/.testr.conf new file mode 100644 index 0000000..8a65628 --- /dev/null +++ b/.testr.conf @@ -0,0 +1,4 @@ +[DEFAULT] +test_command=${PYTHON:-python} -m subunit.run discover . $LISTOPT $IDOPTION +test_id_option=--load-list $IDFILE +test_list_option=--list @@ -0,0 +1,26 @@ +Copyright (c) 2010-2012 the extras authors. + +The extras authors are: + * Jonathan Lange + * Martin Pool + * Robert Collins + +and are collectively referred to as "extras developers". + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..da2696e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include LICENSE +include Makefile +include MANIFEST.in +include NEWS +include README.rst +include .gitignore diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9d5b515 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +# See README.rst for copyright and licensing details. + +PYTHON=python +SOURCES=$(shell find extras -name "*.py") + +check: + PYTHONPATH=$(PWD) $(PYTHON) -m testtools.run extras.tests.test_suite + +TAGS: ${SOURCES} + ctags -e -R extras/ + +tags: ${SOURCES} + ctags -R extras/ + +clean: clean-sphinx + rm -f TAGS tags + find extras -name "*.pyc" -exec rm '{}' \; + +prerelease: + # An existing MANIFEST breaks distutils sometimes. Avoid that. + -rm MANIFEST + +release: + ./setup.py sdist upload --sign + $(PYTHON) scripts/_lp_release.py + +snapshot: prerelease + ./setup.py sdist + +### Documentation ### + +apidocs: + # pydoctor emits deprecation warnings under Ubuntu 10.10 LTS + PYTHONWARNINGS='ignore::DeprecationWarning' \ + pydoctor --make-html --add-package extras \ + --docformat=restructuredtext --project-name=extras \ + --project-url=https://launchpad.net/extras + +doc/news.rst: + ln -s ../NEWS doc/news.rst + +docs: doc/news.rst docs-sphinx + rm doc/news.rst + +docs-sphinx: html-sphinx + +# Clean out generated documentation +clean-sphinx: + cd doc && make clean + +# Build the html docs using Sphinx. +html-sphinx: + cd doc && make html + +.PHONY: apidocs docs-sphinx clean-sphinx html-sphinx docs +.PHONY: check clean prerelease release @@ -0,0 +1,15 @@ +extras NEWS ++++++++++++ + +Changes and improvements to extras_, grouped by release. + +NEXT +~~~~ + +0.0.1 +~~~~~ + +Initial extraction from testtools. + + +.. _extras: http://pypi.python.org/pypi/extras diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..cb5c4d8 --- /dev/null +++ b/README.rst @@ -0,0 +1,49 @@ +====== +extras +====== + +extras is a set of extensions to the Python standard library, originally +written to make the code within testtools cleaner, but now split out for +general use outside of a testing context. + + +Documentation +------------- + +pydoc extras is your friend. + + +Licensing +--------- + +This project is distributed under the MIT license and copyright is owned by +the extras authors. See LICENSE for details. + + +Required Dependencies +--------------------- + + * Python 2.6+ or 3.0+ + + +Bug reports and patches +----------------------- + +Please report bugs using github issues at <https://github.com/testing-cabal/extras>. +Patches can also be submitted via github. You can mail the authors directly +via the mailing list testtools-dev@lists.launchpad.net. (Note that Launchpad +discards email from unknown addresses - be sure to sign up for a Launchpad +account before mailing the list, or your mail will be silently discarded). + + +History +------- + +extras used to be testtools.helpers, and was factored out when folk wanted to +use it separately. + + +Thanks +------ + + * Martin Pool diff --git a/extras/__init__.py b/extras/__init__.py new file mode 100644 index 0000000..bf7470b --- /dev/null +++ b/extras/__init__.py @@ -0,0 +1,105 @@ +# Copyright (c) 2010-2012 extras developers. See LICENSE for details. + +"""Extensions to the Python standard library.""" + +import sys + +__all__ = [ + 'safe_hasattr', + 'try_import', + 'try_imports', + ] + +# same format as sys.version_info: "A tuple containing the five components of +# the version number: major, minor, micro, releaselevel, and serial. All +# values except releaselevel are integers; the release level is 'alpha', +# 'beta', 'candidate', or 'final'. The version_info value corresponding to the +# Python version 2.0 is (2, 0, 0, 'final', 0)." Additionally we use a +# releaselevel of 'dev' for unreleased under-development code. +# +# If the releaselevel is 'alpha' then the major/minor/micro components are not +# established at this point, and setup.py will use a version of next-$(revno). +# If the releaselevel is 'final', then the tarball will be major.minor.micro. +# Otherwise it is major.minor.micro~$(revno). + +__version__ = (0, 0, 1, 'dev', 0) + + +def try_import(name, alternative=None, error_callback=None): + """Attempt to import ``name``. If it fails, return ``alternative``. + + When supporting multiple versions of Python or optional dependencies, it + is useful to be able to try to import a module. + + :param name: The name of the object to import, e.g. ``os.path`` or + ``os.path.join``. + :param alternative: The value to return if no module can be imported. + Defaults to None. + :param error_callback: If non-None, a callable that is passed the ImportError + when the module cannot be loaded. + """ + module_segments = name.split('.') + last_error = None + while module_segments: + module_name = '.'.join(module_segments) + try: + module = __import__(module_name) + except ImportError: + last_error = sys.exc_info()[1] + module_segments.pop() + continue + else: + break + else: + if last_error is not None and error_callback is not None: + error_callback(last_error) + return alternative + nonexistent = object() + for segment in name.split('.')[1:]: + module = getattr(module, segment, nonexistent) + if module is nonexistent: + if last_error is not None and error_callback is not None: + error_callback(last_error) + return alternative + return module + + +_RAISE_EXCEPTION = object() +def try_imports(module_names, alternative=_RAISE_EXCEPTION, error_callback=None): + """Attempt to import modules. + + Tries to import the first module in ``module_names``. If it can be + imported, we return it. If not, we go on to the second module and try + that. The process continues until we run out of modules to try. If none + of the modules can be imported, either raise an exception or return the + provided ``alternative`` value. + + :param module_names: A sequence of module names to try to import. + :param alternative: The value to return if no module can be imported. + If unspecified, we raise an ImportError. + :param error_callback: If None, called with the ImportError for *each* + module that fails to load. + :raises ImportError: If none of the modules can be imported and no + alternative value was specified. + """ + module_names = list(module_names) + for module_name in module_names: + module = try_import(module_name, error_callback=error_callback) + if module: + return module + if alternative is _RAISE_EXCEPTION: + raise ImportError( + "Could not import any of: %s" % ', '.join(module_names)) + return alternative + + +def safe_hasattr(obj, attr, _marker=object()): + """Does 'obj' have an attribute 'attr'? + + Use this rather than built-in hasattr, as the built-in swallows exceptions + in some versions of Python and behaves unpredictably with respect to + properties. + """ + return getattr(obj, attr, _marker) is not _marker + + diff --git a/extras/tests/__init__.py b/extras/tests/__init__.py new file mode 100644 index 0000000..e0d7d4a --- /dev/null +++ b/extras/tests/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2010-2012 extras developers. See LICENSE for details. + +"""Tests for extras.""" + +from unittest import TestSuite, TestLoader + + +def test_suite(): + from extras.tests import ( + test_extras, + ) + modules = [ + test_extras, + ] + loader = TestLoader() + suites = map(loader.loadTestsFromModule, modules) + return TestSuite(suites) diff --git a/extras/tests/test_extras.py b/extras/tests/test_extras.py new file mode 100644 index 0000000..be1ed1c --- /dev/null +++ b/extras/tests/test_extras.py @@ -0,0 +1,188 @@ +# Copyright (c) 2010-2012 extras developers. See LICENSE for details. + +from testtools import TestCase +from testtools.matchers import ( + Equals, + Is, + Not, + ) + +from extras import ( + safe_hasattr, + try_import, + try_imports, + ) + +def check_error_callback(test, function, arg, expected_error_count, + expect_result): + """General test template for error_callback argument. + + :param test: Test case instance. + :param function: Either try_import or try_imports. + :param arg: Name or names to import. + :param expected_error_count: Expected number of calls to the callback. + :param expect_result: Boolean for whether a module should + ultimately be returned or not. + """ + cb_calls = [] + def cb(e): + test.assertIsInstance(e, ImportError) + cb_calls.append(e) + try: + result = function(arg, error_callback=cb) + except ImportError: + test.assertFalse(expect_result) + else: + if expect_result: + test.assertThat(result, Not(Is(None))) + else: + test.assertThat(result, Is(None)) + test.assertEquals(len(cb_calls), expected_error_count) + + +class TestSafeHasattr(TestCase): + + def test_attribute_not_there(self): + class Foo(object): + pass + self.assertEqual(False, safe_hasattr(Foo(), 'anything')) + + def test_attribute_there(self): + class Foo(object): + pass + foo = Foo() + foo.attribute = None + self.assertEqual(True, safe_hasattr(foo, 'attribute')) + + def test_property_there(self): + class Foo(object): + @property + def attribute(self): + return None + foo = Foo() + self.assertEqual(True, safe_hasattr(foo, 'attribute')) + + def test_property_raises(self): + class Foo(object): + @property + def attribute(self): + 1/0 + foo = Foo() + self.assertRaises(ZeroDivisionError, safe_hasattr, foo, 'attribute') + + +class TestTryImport(TestCase): + + def test_doesnt_exist(self): + # try_import('thing', foo) returns foo if 'thing' doesn't exist. + marker = object() + result = try_import('doesntexist', marker) + self.assertThat(result, Is(marker)) + + def test_None_is_default_alternative(self): + # try_import('thing') returns None if 'thing' doesn't exist. + result = try_import('doesntexist') + self.assertThat(result, Is(None)) + + def test_existing_module(self): + # try_import('thing', foo) imports 'thing' and returns it if it's a + # module that exists. + result = try_import('os', object()) + import os + self.assertThat(result, Is(os)) + + def test_existing_submodule(self): + # try_import('thing.another', foo) imports 'thing' and returns it if + # it's a module that exists. + result = try_import('os.path', object()) + import os + self.assertThat(result, Is(os.path)) + + def test_nonexistent_submodule(self): + # try_import('thing.another', foo) imports 'thing' and returns foo if + # 'another' doesn't exist. + marker = object() + result = try_import('os.doesntexist', marker) + self.assertThat(result, Is(marker)) + + def test_object_from_module(self): + # try_import('thing.object') imports 'thing' and returns + # 'thing.object' if 'thing' is a module and 'object' is not. + result = try_import('os.path.join') + import os + self.assertThat(result, Is(os.path.join)) + + def test_error_callback(self): + # the error callback is called on failures. + check_error_callback(self, try_import, 'doesntexist', 1, False) + + def test_error_callback_missing_module_member(self): + # the error callback is called on failures to find an object + # inside an existing module. + check_error_callback(self, try_import, 'os.nonexistent', 1, False) + + def test_error_callback_not_on_success(self): + # the error callback is not called on success. + check_error_callback(self, try_import, 'os.path', 0, True) + + +class TestTryImports(TestCase): + + def test_doesnt_exist(self): + # try_imports('thing', foo) returns foo if 'thing' doesn't exist. + marker = object() + result = try_imports(['doesntexist'], marker) + self.assertThat(result, Is(marker)) + + def test_fallback(self): + result = try_imports(['doesntexist', 'os']) + import os + self.assertThat(result, Is(os)) + + def test_None_is_default_alternative(self): + # try_imports('thing') returns None if 'thing' doesn't exist. + e = self.assertRaises( + ImportError, try_imports, ['doesntexist', 'noreally']) + self.assertThat( + str(e), + Equals("Could not import any of: doesntexist, noreally")) + + def test_existing_module(self): + # try_imports('thing', foo) imports 'thing' and returns it if it's a + # module that exists. + result = try_imports(['os'], object()) + import os + self.assertThat(result, Is(os)) + + def test_existing_submodule(self): + # try_imports('thing.another', foo) imports 'thing' and returns it if + # it's a module that exists. + result = try_imports(['os.path'], object()) + import os + self.assertThat(result, Is(os.path)) + + def test_nonexistent_submodule(self): + # try_imports('thing.another', foo) imports 'thing' and returns foo if + # 'another' doesn't exist. + marker = object() + result = try_imports(['os.doesntexist'], marker) + self.assertThat(result, Is(marker)) + + def test_fallback_submodule(self): + result = try_imports(['os.doesntexist', 'os.path']) + import os + self.assertThat(result, Is(os.path)) + + def test_error_callback(self): + # One error for every class that doesn't exist. + check_error_callback(self, try_imports, + ['os.doesntexist', 'os.notthiseither'], + 2, False) + check_error_callback(self, try_imports, + ['os.doesntexist', 'os.notthiseither', 'os'], + 2, True) + check_error_callback(self, try_imports, + ['os.path'], + 0, True) + + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c319dc7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[test] +test_module = extras.tests +buffer=1 +catch=1 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..fb7b0c3 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +"""Distutils installer for extras.""" + +from distutils.core import setup +import os.path + +import extras + + +def get_version(): + """Return the version of extras that we are building.""" + version = '.'.join( + str(component) for component in extras.__version__[0:3]) + return version + + +def get_long_description(): + readme_path = os.path.join( + os.path.dirname(__file__), 'README.rst') + return open(manual_path).read() + + +setup(name='extras', + author='Testing cabal', + author_email='testtools-dev@lists.launchpad.net', + url='https://github.com/testing-cabal/extras', + description=('Useful extra bits for Python - things that shold be ' + 'in the standard library'), + long_description=get_long_description(), + version=get_version(), + classifiers=["License :: OSI Approved :: MIT License"], + packages=[ + 'extras', + 'extras.tests', + ], + cmdclass={'test': testtools.TestCommand}) |