Commits

Jason Pellerin  committed e51c2ff

Merged 158-166-config-files. Fixes #158 and #166.

  • Participants
  • Parent commits dc0a53d

Comments (0)

Files changed (25)

   @with_setup decorators. Thanks to tlesher for the bug report (#151).
 - Fixed bugs in handling of context fixtures for tests imported into a
   package. Thanks to Gary Bernhardt for the bug report (#145).
+- Fixed bugs in handling of config files and config file options for plugins
+  excluded by a RestrictedPluginManager. Thanks to John J Lee and Philip
+  Jenvey for the bug reports and patches (#158, #166).
   
 0.10.1
 

File functional_tests/doc_tests/test_init_plugin/init_plugin.rst

    output to remove timings, which will vary from run to run, and
    redirects the output to stdout.
 
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
 
 ..
 

File functional_tests/doc_tests/test_issue089/unwanted_package.rst

    output to remove timings, which will vary from run to run, and
    redirects the output to stdout.
 
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
 
 ..
 

File functional_tests/doc_tests/test_issue097/plugintest_environment.rst

 
 ``nose.plugins.plugintest.run()`` should work analogously.
 
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
 
     >>> run(suite=unittest.TestSuite(tests=[]),
     ...     plugins=[PrintEnvPlugin()]) # doctest: +REPORT_NDIFF

File functional_tests/doc_tests/test_issue107/plugin_exceptions.rst

     >>> import os
     >>> import sys
     >>> from nose.plugins import Plugin
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
 
 Our first test plugins take no command-line arguments and raises
 AttributeError in beforeTest and afterTest. 

File functional_tests/doc_tests/test_issue119/empty_plugin.rst

     ...         pass
 
     >>> import unittest
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
 
     >>> run(suite=unittest.TestSuite(tests=[]),
     ...     plugins=[NullPlugin()]) # doctest: +REPORT_NDIFF

File functional_tests/doc_tests/test_issue142/errorclass_failure.rst

     >>> import os
     >>> import sys
 
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
     >>> from nose.plugins.skip import Skip
     >>> from nose.plugins.deprecated import Deprecated
 

File functional_tests/doc_tests/test_issue145/imported_tests.rst

    output to remove timings, which will vary from run to run, and
    redirects the output to stdout.
 
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
 
 ..
 

File functional_tests/doc_tests/test_restricted_plugin_options/restricted_plugin_options.rst

+Restricted Plugin Managers
+--------------------------
+
+In some cases, such as running under the ``python setup.py test`` command,
+nose is not able to use all available plugins. In those cases, a
+`nose.plugins.manager.RestrictedPluginManager` is used to exclude plugins that
+implement API methods that nose is unable to call.
+
+Support files for this test are in the support directory.
+
+    >>> import os
+    >>> support = os.path.join(os.path.dirname(__file__), 'support')
+
+For this test, we'll use a simple plugin that implements the ``startTest``
+method.
+
+    >>> from nose.plugins.base import Plugin
+    >>> from nose.plugins.manager import RestrictedPluginManager
+    >>> class StartPlugin(Plugin):
+    ...     def startTest(self, test):
+    ...         print "started %s" % test
+
+.. Note ::
+
+   The run() function in `nose.plugins.plugintest`_ reformats test result
+   output to remove timings, which will vary from run to run, and
+   redirects the output to stdout.
+
+    >>> from nose.plugins.plugintest import run_buffered as run
+
+..
+
+    When run with a normal plugin manager, the plugin executes.
+
+    >>> argv = ['plugintest', '-v', '--with-startplugin', support]
+    >>> run(argv=argv, plugins=[StartPlugin()]) # doctest: +REPORT_NDIFF
+    started test.test
+    test.test ... ok
+    <BLANKLINE>
+    ----------------------------------------------------------------------
+    Ran 1 test in ...s
+    <BLANKLINE>
+    OK
+
+However, when run with a restricted plugin manager configured to exclude
+plugins implementing `startTest`, an exception is raised and nose exits.
+
+    >>> restricted = RestrictedPluginManager(
+    ...     plugins=[StartPlugin()], exclude=('startTest',), load=False)
+    >>> run(argv=argv, plugins=restricted) #doctest: +REPORT_NDIFF +ELLIPSIS
+    Traceback (most recent call last):
+    ...
+    SystemExit: ...
+
+Errors are only raised when options defined by excluded plugins are used.
+
+    >>> argv = ['plugintest', '-v', support]
+    >>> run(argv=argv, plugins=restricted) # doctest: +REPORT_NDIFF
+    test.test ... ok
+    <BLANKLINE>
+    ----------------------------------------------------------------------
+    Ran 1 test in ...s
+    <BLANKLINE>
+    OK
+
+When a disabled option appears in a configuration file, instead of on the
+command line, a warning is raised instead of an exception.
+
+    >>> argv = ['plugintest', '-v', '-c', os.path.join(support, 'start.cfg'),
+    ...         support]
+    >>> run(argv=argv, plugins=restricted) # doctest: +ELLIPSIS
+    RuntimeWarning: Option 'with-startplugin' in config file '...start.cfg' ignored: excluded by runtime environment
+    test.test ... ok
+    <BLANKLINE>
+    ----------------------------------------------------------------------
+    Ran 1 test in ...s
+    <BLANKLINE>
+    OK
+
+However, if an option appears in a configuration file that is not recognized
+either as an option defined by nose, or by an active or excluded plugin, an
+error is raised.
+
+    >>> argv = ['plugintest', '-v', '-c', os.path.join(support, 'bad.cfg'),
+    ...         support]
+    >>> run(argv=argv, plugins=restricted) # doctest: +ELLIPSIS
+    Traceback (most recent call last):
+    ...
+    ConfigError: Error reading config file '...bad.cfg': no such option 'with-meltedcheese'

File functional_tests/doc_tests/test_restricted_plugin_options/support/bad.cfg

+[nosetests]
+with-meltedcheese=1

File functional_tests/doc_tests/test_restricted_plugin_options/support/start.cfg

+[nosetests]
+with-startplugin=1

File functional_tests/doc_tests/test_restricted_plugin_options/support/test.py

+def test():
+    pass

File functional_tests/doc_tests/test_selector_plugin/selector_plugin.rst

    output to remove timings, which will vary from run to run, and
    redirects the output to stdout.
 
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
 
 ..
 

File functional_tests/test_issue120/test_named_test_with_doctest.rst

 test).
 
     >>> import os
-    >>> from nose.plugins.plugintest import run
+    >>> from nose.plugins.plugintest import run_buffered as run
     >>> from nose.plugins.doctests import Doctest
 
     >>> support = os.path.join(os.path.dirname(__file__), 'support')

File nose/config.py

 import logging
+import optparse
 import os
 import re
 import sys
     ]
 
 
+class NoSuchOptionError(Exception):
+    def __init__(self, name):
+        Exception.__init__(self, name)
+        self.name = name
+
+
+class ConfigError(Exception):
+    pass
+
+
+class ConfiguredDefaultsOptionParser(object):
+    """
+    Handler for options from commandline and config files.
+    """
+    def __init__(self, parser, config_section, error=None, file_error=None):
+        self._parser = parser
+        self._config_section = config_section
+        if error is None:
+            error = self._parser.error
+        self._error = error
+        if file_error is None:
+            file_error = lambda msg, **kw: error(msg)
+        self._file_error = file_error
+
+    def _configTuples(self, cfg, filename):
+        config = []
+        if self._config_section in cfg.sections():
+            for name, value in cfg.items(self._config_section):
+                config.append((name, value, filename))
+        return config
+
+    def _readFromFilenames(self, filenames):
+        config = []
+        for filename in filenames:
+            cfg = ConfigParser.RawConfigParser()
+            try:
+                cfg.read(filename)
+            except ConfigParser.Error, exc:
+                raise ConfigError("Error reading config file %r: %s" %
+                                  (filename, str(exc)))
+            config.extend(self._configTuples(cfg, filename))
+        return config
+
+    def _readFromFileObject(self, fh):
+        cfg = ConfigParser.RawConfigParser()
+        try:
+            filename = fh.name
+        except AttributeError:
+            filename = '<???>'
+        try:
+            cfg.readfp(fh)
+        except ConfigParser.Error, exc:
+            raise ConfigError("Error reading config file %r: %s" %
+                              (filename, str(exc)))
+        return self._configTuples(cfg, filename)
+
+    def _readConfiguration(self, config_files):
+        try:
+            config_files.readline
+        except AttributeError:
+            filename_or_filenames = config_files
+            if isinstance(filename_or_filenames, basestring):
+                filenames = [filename_or_filenames]
+            else:
+                filenames = filename_or_filenames
+            config = self._readFromFilenames(filenames)
+        else:
+            fh = config_files
+            config = self._readFromFileObject(fh)
+        return config
+
+    def _processConfigValue(self, name, value, values, parser):
+        opt_str = '--' + name
+        option = parser.get_option(opt_str)
+        if option is None:
+            raise NoSuchOptionError(name)
+        else:
+            option.process(opt_str, value, values, parser)
+
+    def _applyConfigurationToValues(self, parser, config, values, plugins=None):
+        for name, value, filename in config:
+            if name in option_blacklist:
+                continue
+            try:
+                self._processConfigValue(name, value, values, parser)
+            except NoSuchOptionError, exc:
+                self._file_error(
+                    "Error reading config file %r: "
+                    "no such option %r" % (filename, exc.name),
+                    name=name, filename=filename)
+            except optparse.OptionValueError, exc:
+                msg = str(exc).replace('--' + name, repr(name), 1)
+                self._file_error("Error reading config file %r: "
+                                 "%s" % (filename, msg),
+                                 name=name, filename=filename)
+
+    def parseArgsAndConfigFiles(self, args, config_files):
+        values = self._parser.get_default_values()
+        try:
+            config = self._readConfiguration(config_files)
+        except ConfigError, exc:
+            self._error(str(exc))
+        else:
+            self._applyConfigurationToValues(
+                self._parser, config, values)                
+        return self._parser.parse_args(args, values)
+
+
 class Config(object):
     """nose configuration.
 
                                           for k in keys ])
     __str__ = __repr__
 
+    def _parseArgs(self, args, cfg_files):
+        def warn_sometimes(msg, name=None, filename=None):
+            if (hasattr(self.plugins, 'excludedOption') and
+                self.plugins.excludedOption(name)):
+                msg = ("Option %r in config file %r ignored: "
+                       "excluded by runtime environment" %
+                       (name, filename))
+                warn(msg, RuntimeWarning)
+            else:
+                raise ConfigError(msg)
+        parser = ConfiguredDefaultsOptionParser(
+            self.getParser(), self.configSection, file_error=warn_sometimes)
+        return parser.parseArgsAndConfigFiles(args, cfg_files)
+
     def configure(self, argv=None, doc=None):
         """Configure the nose running environment. Execute configure before
         collecting tests with nose.TestCollector to enable output capture and
         env = self.env
         if argv is None:
             argv = sys.argv
-        if hasattr(self, 'files'):
-            argv = self.loadConfig(self.files, argv)
-        parser = self.getParser(doc)        
-        options, args = parser.parse_args(argv)
+
+        cfg_files = getattr(self, 'files', [])
+        options, args = self._parseArgs(argv, cfg_files)
         # If -c --config has been specified on command line,
-        # load those config files to create a new argv set and reparse
-        if options.files:
-            argv = self.loadConfig(options.files, argv)
-            options, args = parser.parse_args(argv)
+        # load those config files and reparse
+        if getattr(options, 'files', []):
+            options, args = self._parseArgs(argv, options.files)
 
         self.options = options
         tests = args[1:]
         """
         return self.getParser(doc).format_help()
 
-    def loadConfig(self, file, argv):
-        """Load config from file (may be filename or file-like object) and
-        push the config into argv.
-        """
-        cfg = ConfigParser.RawConfigParser()
-        try:
-            try:
-                cfg.readfp(file)
-            except AttributeError:
-                # Filename, not a file object
-                cfg.read(file)
-        except ConfigParser.Error, e:
-            warn("Error reading config file %s: %s" % (file, e),
-                 RuntimeWarning)
-            return argv
-        if self.configSection not in cfg.sections():
-            return argv
-        file_argv = []
-        for optname in cfg.options(self.configSection):
-            if optname in option_blacklist:
-                continue
-            value = cfg.get(self.configSection, optname)
-            file_argv.extend(self.cfgToArg(optname, value))
-        # Copy the given args and insert args loaded from file
-        # between the program name (first arg) and the rest
-        combined = argv[:]
-        combined[1:1] = file_argv
-        return combined
-
-    def cfgToArg(self, optname, value, tr=None):
-        if tr is not None:
-            optname = tr(optname)
-        argv = []
-        if flag(value):
-            if _bool(value):
-                argv.append('--' + optname)
-        else:
-            argv.append('--' + optname)
-            argv.append(value)
-        return argv
-
     def pluginOpts(self, parser):
         self.plugins.addOptions(parser, self.env)
 

File nose/plugins/base.py

     def add_options(self, parser, env=os.environ):
         """Non-camel-case version of func name for backwards compatibility.
         """
-        # FIXME raise deprecation warning if wasn't called by wrapper 
+        # FIXME raise deprecation warning if wasn't called by wrapper
         try:
             self.options(parser, env)
             self.can_configure = True

File nose/plugins/manager.py

     an excluded method will be removed from the manager's plugin list
     after plugins are loaded.
     """
-    def __init__(self, plugins=(), exclude=()):
+    def __init__(self, plugins=(), exclude=(), load=True):
         DefaultPluginManager.__init__(self, plugins)
+        self.load = load
         self.exclude = exclude
-
+        self.excluded = []
+        self._excludedOpts = None
+        
+    def excludedOption(self, name):
+        if self._excludedOpts is None:
+            from optparse import OptionParser
+            self._excludedOpts = OptionParser(add_help_option=False)
+            for plugin in self.excluded:
+                plugin.options(self._excludedOpts, env={})
+        return self._excludedOpts.get_option('--' + name)
+        
     def loadPlugins(self):
-        DefaultPluginManager.loadPlugins(self)
+        if self.load:
+            DefaultPluginManager.loadPlugins(self)
         allow = []
         for plugin in self.plugins:
             ok = True
             for method in self.exclude:
                 if hasattr(plugin, method):
-                    warn("Exclude plugin %s: implements %s" % (plugin, method),
-                         RuntimeWarning)
                     ok = False
+                    self.excluded.append(plugin)
                     break
             if ok:
                 allow.append(plugin)

File nose/plugins/plugintest.py

 
 import re
 import sys
+from warnings import warn
+
 try:
     from cStringIO import StringIO
 except ImportError:
     return "".join(blocks)
 
 
+def simplify_warnings(out):
+    warn_re = re.compile(r"""
+        # Cut the file and line no, up to the warning name
+        ^.*:\d+:\s
+        (?P<category>\w+): \s+        # warning category
+        (?P<detail>.+) $ \n?          # warning message
+        ^ .* $                        # stack frame
+        """, re.VERBOSE | re.MULTILINE)
+    return warn_re.sub(r"\g<category>: \g<detail>", out)
+
+
 def munge_nose_output_for_doctest(out):
     """Modify nose output to make it easy to use in doctests."""
     out = remove_stack_traces(out)
+    out = simplify_warnings(out)
     return re.sub(
         r"Ran (\d+ tests?) in [0-9.]+s", r"Ran \1 in ...s", out).strip()
 
     from nose.plugins.manager import PluginManager
 
     buffer = StringIO()
-    # So prints will be in correct place in output
+    if 'config' not in kw:
+        plugins = kw.pop('plugins', [])
+        if isinstance(plugins, list):
+            plugins = PluginManager(plugins=plugins)
+        env = kw.pop('env', {})
+        kw['config'] = Config(env=env, plugins=plugins)
+    if 'argv' not in kw:
+        kw['argv'] = ['nosetests', '-v']
+    kw['config'].stream = buffer
+    
+    # Set up buffering so that all output goes to our buffer,
+    # or warn user if deprecated behavior is active. If this is not
+    # done, prints and warnings will either be out of place or
+    # disappear.
+    stderr = sys.stderr
     stdout = sys.stdout
-    sys.stdout = buffer
+    if kw.pop('buffer_all', False):
+        sys.stdout = sys.stderr = buffer
+        restore = True
+    else:
+        restore = False
+        warn("The behavior of nose.plugins.plugintest.run() will change in "
+             "the next release of nose. The current behavior does not "
+             "correctly account for output to stdout and stderr. To enable "
+             "correct behavior, use run_buffered() instead, or pass "
+             "the keyword argument buffer_all=True to run().",
+             DeprecationWarning, stacklevel=2)
     try:
-        if 'config' not in kw:
-            plugins = kw.pop('plugins', None)
-            env = kw.pop('env', {})
-            kw['config'] = Config(env=env,
-                                  plugins=PluginManager(plugins=plugins))
-        if 'argv' not in kw:
-            kw['argv'] = ['nosetests', '-v']
-        kw['config'].stream = buffer
         run(*arg, **kw)
-        out = buffer.getvalue()        
-        print >> stdout, munge_nose_output_for_doctest(out)
     finally:
-        sys.stdout = stdout
+        if restore:
+            sys.stderr = stderr
+            sys.stdout = stdout
+    out = buffer.getvalue()
+    print munge_nose_output_for_doctest(out)
 
+    
+def run_buffered(*arg, **kw):
+    kw['buffer_all'] = True
+    run(*arg, **kw)
 
 if __name__ == '__main__':
     import doctest

File nose/plugins/prof.py

             self.enabled = False
             return
         Plugin.configure(self, options, conf)
-        self.options = options
         self.conf = conf
         if options.profile_stats_file:
             self.pfile = options.profile_stats_file

File unit_tests/support/config_defaults/a.cfg

+[nosetests]
+verbosity = 3

File unit_tests/support/config_defaults/b.cfg

+[nosetests]
+verbosity = 5

File unit_tests/support/config_defaults/invalid.cfg

+spam

File unit_tests/support/config_defaults/invalid_value.cfg

+[nosetests]
+verbosity = spam

File unit_tests/test_config_defaults.rst

+    >>> from optparse import OptionParser
+    >>> import os
+    >>> from cStringIO import StringIO
+
+    >>> import nose.config
+
+All commandline options to fall back to values configured in
+configuration files.  The configuration lives in a single section
+("nosetests") in each configuration file.
+
+    >>> support = os.path.join(os.path.dirname(__file__), "support",
+    ...                        "config_defaults")
+
+    >>> def error(msg):
+    ...     print "error: %s" % msg
+
+    >>> def get_parser():
+    ...     parser = OptionParser()
+    ...     parser.add_option(
+    ...         "-v", "--verbose",
+    ...         action="count", dest="verbosity",
+    ...         default=1)
+    ...     parser.add_option(
+    ...         "--verbosity", action="store", dest="verbosity",
+    ...         type="int")
+    ...     return nose.config.ConfiguredDefaultsOptionParser(parser,
+    ...                                                       "nosetests",
+    ...                                                       error)
+
+    >>> def parse(args, config_files):
+    ...     argv = ["nosetests"] + list(args)
+    ...     return get_parser().parseArgsAndConfigFiles(argv, config_files)
+
+
+Options on the command line combine with the defaults from the config
+files and the options' own defaults (here, -v adds 1 to verbosity of 3
+from a.cfg).  Config file defaults take precedence over options'
+defaults.
+
+    >>> options, args = parse([], [])
+    >>> options.verbosity
+    1
+    >>> options, args = parse([], os.path.join(support, "a.cfg"))
+    >>> options.verbosity
+    3
+    >>> options, args = parse(["-v"], os.path.join(support, "a.cfg"))
+    >>> options.verbosity
+    4
+
+Command line arguments take precedence
+
+    >>> options, args = parse(["--verbosity=7"], os.path.join(support, "a.cfg"))
+    >>> options.verbosity
+    7
+
+Where options appear in several config files, the last config file wins
+
+    >>> files = [os.path.join(support, "b.cfg"), os.path.join(support, "a.cfg")]
+    >>> options, args = parse([], files)
+    >>> options.verbosity
+    3
+
+
+Invalid values should cause an error specifically about configuration
+files (not about a commandline option)
+
+    >>> options, arguments = parse([], StringIO("""\
+    ... [nosetests]
+    ... verbosity = spam
+    ... """))
+    error: Error reading config file '<???>': option 'verbosity': invalid integer value: 'spam'
+
+Unrecognised option in nosetests config section
+
+    >>> options, args = parse([], StringIO("[nosetests]\nspam=eggs\n"))
+    error: Error reading config file '<???>': no such option 'spam'
+
+If there were multiple config files, the error message tells us which
+file contains the bad option name or value
+
+    >>> options, args = parse([], [os.path.join(support, "a.cfg"),
+    ...                            os.path.join(support, "invalid_value.cfg"),
+    ...                            os.path.join(support, "b.cfg")])
+    ... # doctest: +ELLIPSIS
+    error: Error reading config file '.../invalid_value.cfg': option 'verbosity': invalid integer value: 'spam'
+
+
+Invalid config files
+
+(file-like object)
+
+    >>> options, args = parse([], StringIO("spam"))
+    error: Error reading config file '<???>': File contains no section headers.
+    file: <???>, line: 1
+    'spam'
+
+(filename)
+
+    >>> options, args = parse([], os.path.join(support, "invalid.cfg"))
+    ... # doctest: +ELLIPSIS
+    error: Error reading config file '.../invalid.cfg': File contains no section headers.
+    file: .../invalid.cfg, line: 1
+    'spam\n'
+
+(filenames, length == 1)
+
+    >>> options, args = parse([], [os.path.join(support, "invalid.cfg")])
+    ... # doctest: +ELLIPSIS
+    error: Error reading config file '.../invalid.cfg': File contains no section headers.
+    file: .../invalid.cfg, line: 1
+    'spam\n'
+
+(filenames, length > 1)
+
+If there were multiple config files, the error message tells us which
+file is bad
+
+    >>> options, args = parse([], [os.path.join(support, "a.cfg"),
+    ...                            os.path.join(support, "invalid.cfg"),
+    ...                            os.path.join(support, "b.cfg")])
+    ... # doctest: +ELLIPSIS
+    error: Error reading config file '.../invalid.cfg': File contains no section headers.
+    file: .../invalid.cfg, line: 1
+    'spam\n'
+
+
+Missing config files don't deserve an error or warning
+
+(filename)
+
+    >>> options, args = parse([], os.path.join(support, "nonexistent.cfg"))
+    >>> print options.__dict__
+    {'verbosity': 1}
+
+(filenames)
+
+    >>> options, args = parse([], [os.path.join(support, "nonexistent.cfg")])
+    >>> print options.__dict__
+    {'verbosity': 1}
+
+
+The same goes for missing config file section ("nosetests")
+
+    >>> options, args = parse([], StringIO("[spam]\nfoo=bar\n"))
+    >>> print options.__dict__
+    {'verbosity': 1}

File unit_tests/test_ls_tree.rst

+    >>> import os
+    >>> import tempfile
+    >>> import shutil
+
+    >>> from nose.util import ls_tree
+
+    >>> dir_path = tempfile.mkdtemp()
+
+    >>> def create_file(filename):
+    ...     fd = os.open(filename, os.O_WRONLY|os.O_CREAT, 0666)
+    ...     os.close(fd)
+
+    >>> os.mkdir(os.path.join(dir_path, "top"))
+    >>> os.mkdir(os.path.join(dir_path, "top/dir"))
+    >>> os.mkdir(os.path.join(dir_path, "top/dir2"))
+    >>> os.mkdir(os.path.join(dir_path, "top/dir3"))
+    >>> os.mkdir(os.path.join(dir_path, "top/dir/dir"))
+    >>> os.mkdir(os.path.join(dir_path, "top/dir/dir2"))
+    >>> os.mkdir(os.path.join(dir_path, "top/.svn"))
+    >>> os.mkdir(os.path.join(dir_path, "top/.notsvn"))
+    >>> os.mkdir(os.path.join(dir_path, "top/dir/.svn"))
+    >>> os.mkdir(os.path.join(dir_path, "top/dir/.notsvn"))
+    >>> create_file(os.path.join(dir_path, "top/file"))
+    >>> create_file(os.path.join(dir_path, "top/backup_file~"))
+    >>> create_file(os.path.join(dir_path, "top/file2"))
+    >>> create_file(os.path.join(dir_path, "top/dir/file"))
+    >>> create_file(os.path.join(dir_path, "top/dir/dir/file"))
+    >>> create_file(os.path.join(dir_path, "top/dir/dir/file2"))
+    >>> create_file(os.path.join(dir_path, "top/dir/backup_file~"))
+    >>> create_file(os.path.join(dir_path, "top/dir2/file"))
+
+    Note that files matching skip_pattern (by default SVN files,
+    backup files and compiled Python files) are ignored
+
+    >>> print ls_tree(os.path.join(dir_path, "top"))
+    |-- file
+    |-- file2
+    |-- .notsvn
+    |-- dir
+    |   |-- file
+    |   |-- .notsvn
+    |   |-- dir
+    |   |   |-- file
+    |   |   `-- file2
+    |   `-- dir2
+    |-- dir2
+    |   `-- file
+    `-- dir3
+
+    >>> shutil.rmtree(dir_path)