Beispiel #1
0
    def test_show_entry(self):
        """Test showing of an entry on the terminal."""
        password = random_string()
        # Some voodoo to mock methods in classes that
        # have yet to be instantiated follows :-).
        mocked_class = type(
            'TestPasswordEntry',
            (PasswordEntry, ),
            dict(text=password),
        )
        with PatchedAttribute(qpass, 'PasswordEntry', mocked_class):
            with TemporaryDirectory() as directory:
                name = 'some/random/password'
                touch(os.path.join(directory, '%s.gpg' % name))
                returncode, output = run_cli(
                    main,
                    '--password-store=%s' % directory,
                    '--no-clipboard',
                    name,
                )
                assert returncode == 0
                assert dedent(output) == dedent(
                    """
                    {title}

                    Password: {password}
                    """,
                    title=name.replace('/', ' / '),
                    password=password,
                )
Beispiel #2
0
def create_proxy_method(name):
    """
    Create a proxy method for use by :func:`enable_old_api()`.

    :param name: The name of the :class:`PseudoTerminal` method to call when
                 the proxy method is called.
    :returns: A proxy method (a callable) to be installed on the
              :class:`CaptureOutput` class.
    """

    # Define the proxy method.
    def proxy_method(self, *args, **kw):
        if not hasattr(self, 'output'):
            raise TypeError(
                compact("""
                The old calling interface is only supported when
                merged=True and start_capture() has been called!
            """))
        real_method = getattr(self.output, name)
        return real_method(*args, **kw)

    # Get the docstring of the real method.
    docstring = getattr(PseudoTerminal, name).__doc__
    # Change the docstring to explain that this concerns a proxy method,
    # but only when Sphinx is active (to avoid wasting time generating a
    # docstring that no one is going to look at).
    if 'sphinx' in sys.modules:
        # Remove the signature from the docstring to make it possible to
        # remove leading indentation from the remainder of the docstring.
        lines = docstring.splitlines()
        signature = lines.pop(0)
        # Recompose the docstring from the signature, the remainder of the
        # original docstring and the note about proxy methods.
        docstring = '\n\n'.join([
            signature,
            dedent('\n'.join(lines)),
            dedent("""
                .. note:: This method is a proxy for the :func:`~PseudoTerminal.{name}()`
                          method of the :class:`PseudoTerminal` class. It requires
                          `merged` to be :data:`True` and it expects that
                          :func:`start_capture()` has been called. If this is not
                          the case then :exc:`~exceptions.TypeError` is raised.
            """,
                   name=name),
        ])
    # Copy the (possible modified) docstring.
    proxy_method.__doc__ = docstring
    return proxy_method
Beispiel #3
0
 def test_conversion_with_system_package(self):
     """Convert a package and map one of its requirements to a system package."""
     with TemporaryDirectory() as repository_directory:
         with TemporaryDirectory() as distribution_directory:
             # Create a temporary (and rather trivial :-) Python package.
             with open(os.path.join(distribution_directory, 'setup.py'),
                       'w') as handle:
                 handle.write(
                     dedent('''
                     from setuptools import setup
                     setup(
                         name='system-package-conversion-test',
                         version='1.0',
                         install_requires=['dbus-python'],
                     )
                 '''))
             # Run the conversion command.
             converter = self.create_isolated_converter()
             converter.set_repository(repository_directory)
             converter.use_system_package('dbus-python',
                                          fix_name_prefix('python-dbus'))
             archives, relationships = converter.convert(
                 [distribution_directory])
             # Make sure only one archive was generated.
             assert len(archives) == 1
             # Use deb-pkg-tools to inspect the package metadata.
             metadata, contents = inspect_package(archives[0])
             logger.debug("Metadata of generated package: %s",
                          dict(metadata))
             logger.debug("Contents of generated package: %s",
                          dict(contents))
             # Inspect the converted package's dependency.
             assert metadata['Depends'].matches(fix_name_prefix('python-dbus')), \
                 "py2deb failed to rewrite dependency name!"
Beispiel #4
0
def cryptdisks_stop_cli():
    """
    Usage: cryptdisks-stop-fallback NAME

    Reads /etc/crypttab and locks the encrypted filesystem with the given NAME.

    This program emulates the functionality of Debian's cryptdisks_stop program,
    but it only supports LUKS encryption and a small subset of the available
    encryption options.
    """
    # Enable logging to the terminal and system log.
    coloredlogs.install(syslog=True)
    # Get the name of the encrypted filesystem from the command line arguments
    # and show a simple usage message when no name is given as an argument.
    try:
        target = sys.argv[1]
    except IndexError:
        usage(dedent(cryptdisks_stop_cli.__doc__))
    else:
        # Call our Python implementation of `cryptdisks_stop'.
        try:
            cryptdisks_stop(target)
        except ValueError as e:
            # cryptdisks_stop() raises ValueError when the given target isn't
            # listed in /etc/crypttab. This doesn't deserve a traceback on the
            # terminal.
            warning("Error: %s", e)
            sys.exit(1)
        except Exception as e:
            # Any other exceptions are logged to the terminal and system log.
            logger.exception("Aborting due to exception!")
            sys.exit(1)
Beispiel #5
0
    def test_install_requires_version_munging(self):
        """
        Convert a package with a requirement whose version is "munged" by pip.

        Refer to :func:`py2deb.converter.PackageConverter.transform_version()`
        for details about the purpose of this test.
        """
        with TemporaryDirectory() as repository_directory:
            with TemporaryDirectory() as distribution_directory:
                # Create a temporary (and rather trivial :-) Python package.
                with open(os.path.join(distribution_directory, 'setup.py'), 'w') as handle:
                    handle.write(dedent('''
                        from setuptools import setup
                        setup(
                            name='install-requires-munging-test',
                            version='1.0',
                            install_requires=['humanfriendly==1.30.0'],
                        )
                    '''))
                # Run the conversion command.
                converter = self.create_isolated_converter()
                converter.set_repository(repository_directory)
                archives, relationships = converter.convert([distribution_directory])
                # Find the generated *.deb archive.
                pathname = find_package_archive(archives, 'python-install-requires-munging-test')
                # Use deb-pkg-tools to inspect the package metadata.
                metadata, contents = inspect_package(pathname)
                logger.debug("Metadata of generated package: %s", dict(metadata))
                logger.debug("Contents of generated package: %s", dict(contents))
                # Inspect the converted package's dependency.
                assert metadata['Depends'].matches('python-humanfriendly', '1.30'), \
                    "py2deb failed to rewrite version of dependency!"
                assert not metadata['Depends'].matches('python-humanfriendly', '1.30.0'), \
                    "py2deb failed to rewrite version of dependency!"
Beispiel #6
0
    def test_conversion_with_configuration_file(self):
        """
        Convert a group of packages based on the settings in a configuration file.

        Repeats the same test as :func:`test_conversion_of_isolated_packages()`
        but instead of using command line options the conversion process is
        configured using a configuration file.
        """
        # Use a temporary directory as py2deb's repository directory so that we
        # can easily find the *.deb archive generated by py2deb.
        with TemporaryDirectory() as directory:
            configuration_file = os.path.join(directory, 'py2deb.ini')
            with open(configuration_file, 'w') as handle:
                handle.write(dedent('''
                    [py2deb]
                    repository = {repository}
                    name-prefix = pip-accel
                    install-prefix = /usr/lib/pip-accel
                    auto-install = false

                    [alternatives]
                    /usr/bin/pip-accel = /usr/lib/pip-accel/bin/pip-accel

                    [package:pip-accel]
                    no-name-prefix = true

                    [package:coloredlogs]
                    rename = pip-accel-coloredlogs-renamed
                ''', repository=directory))
            # Run the conversion command.
            exit_code, output = run_cli(main, '--config=%s' % configuration_file, 'pip-accel==0.12.6')
            assert exit_code == 0
            # Check the results.
            self.check_converted_pip_accel_packages(directory)
def parse_usage(text):
    """
    Parse a usage message by inferring its structure (and making some assumptions :-).

    :param text: The usage message to parse (a string).
    :returns: A tuple of two lists:

              1. A list of strings with the paragraphs of the usage message's
                 "introduction" (the paragraphs before the documentation of the
                 supported command line options).

              2. A list of strings with pairs of command line options and their
                 descriptions: Item zero is a line listing a supported command
                 line option, item one is the description of that command line
                 option, item two is a line listing another supported command
                 line option, etc.

    Usage messages in general are free format of course, however
    :func:`parse_usage()` assume a certain structure from usage messages in
    order to successfully parse them:

    - The usage message starts with a line ``Usage: ...`` that shows a symbolic
      representation of the way the program is to be invoked.

    - After some free form text a line ``Supported options:`` precedes the
      documentation of the supported command line options.

    - The command line options are documented as follows::

        -v, --verbose

          Make more noise.

      So all of the variants of the command line option are shown together on a
      separate line, followed by one or more paragraphs describing the option.

    - There are several other minor assumptions, but to be honest I'm not sure if
      anyone other than me is ever going to use this functionality, so for now I
      won't list every intricate detail :-).

      If you're curious anyway, refer to the usage message of the `humanfriendly`
      package (defined in the :mod:`humanfriendly.cli` module) and compare it with
      the usage message you see when you run ``humanfriendly --help`` and the
      generated usage message embedded in the readme.

      Feel free to request more detailed documentation if you're interested in
      using the :mod:`humanfriendly.usage` module outside of the little ecosystem
      of Python packages that I have been building over the past years.

    """
    # Split the raw usage message on lines that introduce a new command line option.
    chunks = [dedent(c) for c in re.split(OPTION_PATTERN, text) if c and not c.isspace()]
    # Split the introduction (the text before any options) into one or more paragraphs.
    introduction = [join_lines(p) for p in split_paragraphs(chunks.pop(0))]
    # Should someone need to easily debug the parsing performed here.
    logger.debug("Parsed introduction: %s", introduction)
    logger.debug("Parsed options: %s", chunks)
    return introduction, chunks
Beispiel #8
0
    def test_format_text(self):
        """Test human friendly formatting of password store entries."""
        entry = PasswordEntry(name='some/random/password', store=object())
        set_property(entry, 'text', random_string())
        self.assertEquals(
            # We enable ANSI escape sequences but strip them before we
            # compare the generated string. This may seem rather pointless
            # but it ensures that the relevant code paths are covered :-).
            dedent(
                ansi_strip(
                    entry.format_text(include_password=True,
                                      use_colors=True))),
            dedent('''
                some / random / password

                Password: {value}
            ''',
                   value=entry.text))
Beispiel #9
0
def create_proxy_method(name):
    """
    Create a proxy method for use by :func:`enable_old_api()`.

    :param name: The name of the :class:`PseudoTerminal` method to call when
                 the proxy method is called.
    :returns: A proxy method (a callable) to be installed on the
              :class:`CaptureOutput` class.
    """
    # Define the proxy method.
    def proxy_method(self, *args, **kw):
        if not hasattr(self, 'output'):
            raise TypeError(compact("""
                The old calling interface is only supported when
                merged=True and start_capture() has been called!
            """))
        real_method = getattr(self.output, name)
        return real_method(*args, **kw)
    # Get the docstring of the real method.
    docstring = getattr(PseudoTerminal, name).__doc__
    # Change the docstring to explain that this concerns a proxy method,
    # but only when Sphinx is active (to avoid wasting time generating a
    # docstring that no one is going to look at).
    if 'sphinx' in sys.modules:
        # Remove the signature from the docstring to make it possible to
        # remove leading indentation from the remainder of the docstring.
        lines = docstring.splitlines()
        signature = lines.pop(0)
        # Recompose the docstring from the signature, the remainder of the
        # original docstring and the note about proxy methods.
        docstring = '\n\n'.join([
            signature,
            dedent('\n'.join(lines)),
            dedent("""
                .. note:: This method is a proxy for the :func:`~PseudoTerminal.{name}()`
                          method of the :class:`PseudoTerminal` class. It requires
                          `merged` to be :data:`True` and it expects that
                          :func:`start_capture()` has been called. If this is not
                          the case then :exc:`~exceptions.TypeError` is raised.
            """, name=name),
        ])
    # Copy the (possible modified) docstring.
    proxy_method.__doc__ = docstring
    return proxy_method
Beispiel #10
0
def normalize_repr_output(expression):
    """
    Enable string comparison between :func:`repr()` output on different Python versions.

    This function enables string comparison between :func:`repr()` output on
    Python 2 (where Unicode strings have the ``u`` prefix) and Python 3 (where
    Unicode strings are the default and no prefix is emitted by
    :func:`repr()`).
    """
    return re.sub(r'\bu([\'"])', r'\1', dedent(expression).strip())
Beispiel #11
0
def normalize_repr_output(expression):
    """
    Enable string comparison between :func:`repr()` output on different Python versions.

    This function enables string comparison between :func:`repr()` output on
    Python 2 (where Unicode strings have the ``u`` prefix) and Python 3 (where
    Unicode strings are the default and no prefix is emitted by
    :func:`repr()`).
    """
    return re.sub(r'\bu([\'"])', r'\1', dedent(expression).strip())
Beispiel #12
0
 def mock_ip(self):
     """Mocked ``ip route show`` program."""
     return MockedProgram(name='ip',
                          script=dedent("""
         cat << EOF
         default via 192.168.1.1 dev wlp3s0 proto dhcp metric 600
         169.254.0.0/16 dev virbr0 scope link metric 1000 linkdown
         192.168.0.0/16 dev wlp3s0 proto kernel scope link src 192.168.2.214 metric 600
         192.168.122.0/24 dev virbr0 proto kernel scope link src 192.168.122.1 linkdown
         EOF
     """))
def deprecation_note_callback(app, what, name, obj, options, lines):
    """
    Automatically document aliases defined using :func:`~humanfriendly.deprecation.define_aliases()`.

    Refer to :func:`enable_deprecation_notes()` to enable the use of this
    function (you probably don't want to call :func:`deprecation_note_callback()`
    directly).

    This function implements a callback for ``autodoc-process-docstring`` that
    reformats module docstrings to append an overview of aliases defined by the
    module.

    The parameters expected by this function are those defined for Sphinx event
    callback functions (i.e. I'm not going to document them here :-).
    """
    if isinstance(obj, types.ModuleType) and lines:
        aliases = get_aliases(obj.__name__)
        if aliases:
            # Convert the existing docstring to a string and remove leading
            # indentation from that string, otherwise our generated content
            # would have to match the existing indentation in order not to
            # break docstring parsing (because indentation is significant
            # in the reStructuredText format).
            blocks = [dedent("\n".join(lines))]
            # Use an admonition to group the deprecated aliases together and
            # to distinguish them from the autodoc entries that follow.
            blocks.append(".. note:: Deprecated names")
            indent = " " * 3
            if len(aliases) == 1:
                explanation = """
                    The following alias exists to preserve backwards compatibility,
                    however a :exc:`~exceptions.DeprecationWarning` is triggered
                    when it is accessed, because this alias will be removed
                    in a future release.
                """
            else:
                explanation = """
                    The following aliases exist to preserve backwards compatibility,
                    however a :exc:`~exceptions.DeprecationWarning` is triggered
                    when they are accessed, because these aliases will be
                    removed in a future release.
                """
            blocks.append(indent + compact(explanation))
            for name, target in aliases.items():
                blocks.append(format("%s.. data:: %s", indent, name))
                blocks.append(
                    format("%sAlias for :obj:`%s`.", indent * 2, target))
            update_lines(lines, "\n\n".join(blocks))
 def install_sources_file(self):
     """Install a 'package resource list' that points ``apt`` to the NodeSource repository."""
     logger.info("Installing package resource list (%s) ..",
                 self.sources_file)
     sources_list = dedent('''
         # {filename}:
         # Get NodeJS binaries from the NodeSource repository.
         deb https://deb.nodesource.com/{version} {codename} main
         deb-src https://deb.nodesource.com/{version} {codename} main
     ''',
                           filename=self.sources_file,
                           version=self.nodejs_version,
                           codename=self.distribution_codename)
     # TODO It would be nicer if context.write_file() accepted sudo=True!
     self.context.execute('cat > %s' % quote(self.sources_file),
                          input=sources_list,
                          sudo=True)
Beispiel #15
0
 def mock_arp(self):
     """Mocked ``arp`` program."""
     return MockedProgram(name='arp',
                          script=dedent("""
         cat << EOF
         Address                  HWtype  HWaddress           Flags Mask            Iface
         192.168.1.4              ether   4b:21:f5:49:88:85   C                     wlp3s0
         192.168.3.28             ether   3d:a6:19:62:9a:83   C                     wlp3s0
         192.168.3.5              ether   c5:4c:8d:56:25:0c   C                     wlp3s0
         192.168.1.1              ether   80:34:58:ad:6c:f5   C                     wlp3s0
         192.168.3.2              ether   20:22:a0:22:0c:db   C                     wlp3s0
         192.168.1.12             ether   ad:12:75:46:e9:70   C                     wlp3s0
         192.168.3.6              ether   08:33:c7:ef:f7:27   C                     wlp3s0
         192.168.1.11             ether   c9:0e:95:24:68:31   C                     wlp3s0
         192.168.3.4              ether   e7:e6:2c:3b:bc:8a   C                     wlp3s0
         192.168.3.3              ether   72:d7:d3:2c:54:93   C                     wlp3s0
         192.168.1.6              ether   95:ef:85:cf:d3:36   C                     wlp3s0
         192.168.3.7              ether   65:c0:be:40:cd:31   C                     wlp3s0
         EOF
     """))
Beispiel #16
0
    def glob(self, pattern):
        """
        Find matches for a given filename pattern.

        :param pattern: A filename pattern (a string).
        :returns: A list of strings with matches.

        Some implementation notes:

        - This method *emulates* filename globbing as supported by system
          shells like Bash and ZSH. It works by forking a Python interpreter
          and using that to call the :func:`glob.glob()` function. This
          approach is of course rather heavyweight.

        - Initially this method used Bash for filename matching (similar to
          `this StackOverflow answer <https://unix.stackexchange.com/a/34012/44309>`_)
          but I found it impossible to make this work well for patterns
          containing whitespace.

        - I took the whitespace issue as a sign that I was heading down the
          wrong path (trying to add robustness to a fragile solution) and so
          the new implementation was born (which prioritizes robustness over
          performance).
        """
        listing = self.capture(
            'python',
            input=dedent(
                r'''
                import glob
                matches = glob.glob({pattern})
                print('\x00'.join(matches))
                ''',
                pattern=repr(pattern),
            ),
        )
        return split(listing, '\x00')
Beispiel #17
0
    def glob(self, pattern):
        """
        Find matches for a given filename pattern.

        :param pattern: A filename pattern (a string).
        :returns: A list of strings with matches.

        Some implementation notes:

        - This method *emulates* filename globbing as supported by system
          shells like Bash and ZSH. It works by forking a Python interpreter
          and using that to call the :func:`glob.glob()` function. This
          approach is of course rather heavyweight.

        - Initially this method used Bash for filename matching (similar to
          `this StackOverflow answer <https://unix.stackexchange.com/a/34012/44309>`_)
          but I found it impossible to make this work well for patterns
          containing whitespace.

        - I took the whitespace issue as a sign that I was heading down the
          wrong path (trying to add robustness to a fragile solution) and so
          the new implementation was born (which prioritizes robustness over
          performance).
        """
        listing = self.capture(
            'python',
            input=dedent(
                r'''
                import glob
                matches = glob.glob({pattern})
                print('\x00'.join(matches))
                ''',
                pattern=repr(pattern),
            ),
        )
        return split(listing, '\x00')
Beispiel #18
0
def parse_usage(text):
    """
    Parse a usage message by inferring its structure (and making some assumptions :-).

    :param text: The usage message to parse (a string).
    :returns: A tuple of two lists:

              1. A list of strings with the paragraphs of the usage message's
                 "introduction" (the paragraphs before the documentation of the
                 supported command line options).

              2. A list of strings with pairs of command line options and their
                 descriptions: Item zero is a line listing a supported command
                 line option, item one is the description of that command line
                 option, item two is a line listing another supported command
                 line option, etc.

    Usage messages in general are free format of course, however
    :func:`parse_usage()` assume a certain structure from usage messages in
    order to successfully parse them:

    - The usage message starts with a line ``Usage: ...`` that shows a symbolic
      representation of the way the program is to be invoked.

    - After some free form text a line ``Supported options:`` (surrounded by
      empty lines) precedes the documentation of the supported command line
      options.

    - The command line options are documented as follows::

        -v, --verbose

          Make more noise.

      So all of the variants of the command line option are shown together on a
      separate line, followed by one or more paragraphs describing the option.

    - There are several other minor assumptions, but to be honest I'm not sure if
      anyone other than me is ever going to use this functionality, so for now I
      won't list every intricate detail :-).

      If you're curious anyway, refer to the usage message of the `humanfriendly`
      package (defined in the :mod:`humanfriendly.cli` module) and compare it with
      the usage message you see when you run ``humanfriendly --help`` and the
      generated usage message embedded in the readme.

      Feel free to request more detailed documentation if you're interested in
      using the :mod:`humanfriendly.usage` module outside of the little ecosystem
      of Python packages that I have been building over the past years.
    """
    introduction = []
    documented_options = []
    # Split the raw usage message into paragraphs.
    paragraphs = split_paragraphs(text)
    # Get the paragraphs that are part of the introduction.
    while paragraphs:
        # Check whether we've found the end of the introduction.
        end_of_intro = (paragraphs[0] == START_OF_OPTIONS_MARKER)
        # Append the current paragraph to the introduction.
        introduction.append(join_lines(paragraphs.pop(0)))
        # Stop after we've processed the complete introduction.
        if end_of_intro:
            break
    logger.debug("Parsed introduction: %s", introduction)
    # Parse the paragraphs that document command line options.
    while paragraphs:
        documented_options.append(dedent(paragraphs.pop(0)))
        description = []
        while paragraphs:
            # Check if the next paragraph starts the documentation of another
            # command line option.
            if all(OPTION_PATTERN.match(t) for t in split(paragraphs[0])):
                break
            else:
                description.append(paragraphs.pop(0))
        # Join the description's paragraphs back together so we can remove
        # common leading indentation.
        documented_options.append(dedent('\n\n'.join(description)))
    logger.debug("Parsed options: %s", documented_options)
    return introduction, documented_options
def parse_usage(text):
    """
    Parse a usage message by inferring its structure (and making some assumptions :-).

    :param text: The usage message to parse (a string).
    :returns: A tuple of two lists:

              1. A list of strings with the paragraphs of the usage message's
                 "introduction" (the paragraphs before the documentation of the
                 supported command line options).

              2. A list of strings with pairs of command line options and their
                 descriptions: Item zero is a line listing a supported command
                 line option, item one is the description of that command line
                 option, item two is a line listing another supported command
                 line option, etc.

    Usage messages in general are free format of course, however
    :func:`parse_usage()` assume a certain structure from usage messages in
    order to successfully parse them:

    - The usage message starts with a line ``Usage: ...`` that shows a symbolic
      representation of the way the program is to be invoked.

    - After some free form text a line ``Supported options:`` (surrounded by
      empty lines) precedes the documentation of the supported command line
      options.

    - The command line options are documented as follows::

        -v, --verbose

          Make more noise.

      So all of the variants of the command line option are shown together on a
      separate line, followed by one or more paragraphs describing the option.

    - There are several other minor assumptions, but to be honest I'm not sure if
      anyone other than me is ever going to use this functionality, so for now I
      won't list every intricate detail :-).

      If you're curious anyway, refer to the usage message of the `humanfriendly`
      package (defined in the :mod:`humanfriendly.cli` module) and compare it with
      the usage message you see when you run ``humanfriendly --help`` and the
      generated usage message embedded in the readme.

      Feel free to request more detailed documentation if you're interested in
      using the :mod:`humanfriendly.usage` module outside of the little ecosystem
      of Python packages that I have been building over the past years.
    """
    introduction = []
    documented_options = []
    # Split the raw usage message into paragraphs.
    paragraphs = split_paragraphs(text)
    # Get the paragraphs that are part of the introduction.
    while paragraphs:
        # Check whether we've found the end of the introduction.
        end_of_intro = (paragraphs[0] == START_OF_OPTIONS_MARKER)
        # Append the current paragraph to the introduction.
        introduction.append(paragraphs.pop(0))
        # Stop after we've processed the complete introduction.
        if end_of_intro:
            break
    logger.debug("Parsed introduction: %s", introduction)
    # Parse the paragraphs that document command line options.
    while paragraphs:
        documented_options.append(dedent(paragraphs.pop(0)))
        description = []
        while paragraphs:
            # Check if the next paragraph starts the documentation of another
            # command line option. We split on a comma followed by a space so
            # that our parsing doesn't trip up when the label used for an
            # option's value contains commas.
            tokens = [
                t.strip() for t in re.split(r',\s', paragraphs[0])
                if t and not t.isspace()
            ]
            if all(OPTION_PATTERN.match(t) for t in tokens):
                break
            else:
                description.append(paragraphs.pop(0))
        # Join the description's paragraphs back together so we can remove
        # common leading indentation.
        documented_options.append(dedent('\n\n'.join(description)))
    logger.debug("Parsed options: %s", documented_options)
    return introduction, documented_options