# Authors: David Goodger, David Priest
# Contact: goodger@python.org
# Revision: $Revision: 3900 $
# Date: $Date: 2005-09-24 17:11:20 +0200 (Sat, 24 Sep 2005) $
# Copyright: This module has been placed in the public domain.

"""
Directives for table elements.
"""

__docformat__ = 'reStructuredText'


import sys
import os.path
from docutils import io, nodes, statemachine, utils
from docutils.utils import SystemMessagePropagation
from docutils.parsers.rst import directives

try:
    import csv                          # new in Python 2.3
except ImportError:
    csv = None

try:
    import urllib2
except ImportError:
    urllib2 = None

try:
    True
except NameError:                       # Python 2.2 & 2.1 compatibility
    True = not 0
    False = not 1


def table(name, arguments, options, content, lineno,
          content_offset, block_text, state, state_machine):
    if not content:
        warning = state_machine.reporter.warning(
            'Content block expected for the "%s" directive; none found.'
            % name, nodes.literal_block(block_text, block_text),
            line=lineno)
        return [warning]
    title, messages = make_title(arguments, state, lineno)
    node = nodes.Element()          # anonymous container for parsing
    state.nested_parse(content, content_offset, node)
    if len(node) != 1 or not isinstance(node[0], nodes.table):
        error = state_machine.reporter.error(
            'Error parsing content block for the "%s" directive: '
            'exactly one table expected.'
            % name, nodes.literal_block(block_text, block_text),
            line=lineno)
        return [error]
    table_node = node[0]
    table_node['classes'] += options.get('class', [])
    if title:
        table_node.insert(0, title)
    return [table_node] + messages

table.arguments = (0, 1, 1)
table.options = {'class': directives.class_option}
table.content = 1

def make_title(arguments, state, lineno):
    if arguments:
        title_text = arguments[0]
        text_nodes, messages = state.inline_text(title_text, lineno)
        title = nodes.title(title_text, '', *text_nodes)
    else:
        title = None
        messages = []
    return title, messages


if csv:
    class DocutilsDialect(csv.Dialect):

        """CSV dialect for `csv_table` directive function."""

        delimiter = ','
        quotechar = '"'
        doublequote = True
        skipinitialspace = True
        lineterminator = '\n'
        quoting = csv.QUOTE_MINIMAL

        def __init__(self, options):
            if options.has_key('delim'):
                self.delimiter = str(options['delim'])
            if options.has_key('keepspace'):
                self.skipinitialspace = False
            if options.has_key('quote'):
                self.quotechar = str(options['quote'])
            if options.has_key('escape'):
                self.doublequote = False
                self.escapechar = str(options['escape'])
            csv.Dialect.__init__(self)


    class HeaderDialect(csv.Dialect):

        """CSV dialect to use for the "header" option data."""

        delimiter = ','
        quotechar = '"'
        escapechar = '\\'
        doublequote = False
        skipinitialspace = True
        lineterminator = '\n'
        quoting = csv.QUOTE_MINIMAL


def csv_table(name, arguments, options, content, lineno,
             content_offset, block_text, state, state_machine):
    try:
        if ( not state.document.settings.file_insertion_enabled
             and (options.has_key('file') or options.has_key('url')) ):
            warning = state_machine.reporter.warning(
                'File and URL access deactivated; ignoring "%s" directive.' %
                name, nodes.literal_block(block_text,block_text), line=lineno)
            return [warning]
        check_requirements(name, lineno, block_text, state_machine)
        title, messages = make_title(arguments, state, lineno)
        csv_data, source = get_csv_data(
            name, options, content, lineno, block_text, state, state_machine)
        table_head, max_header_cols = process_header_option(
            options, state_machine, lineno)
        rows, max_cols = parse_csv_data_into_rows(
            csv_data, DocutilsDialect(options), source, options)
        max_cols = max(max_cols, max_header_cols)
        header_rows = options.get('header-rows', 0) # default 0
        stub_columns = options.get('stub-columns', 0) # default 0
        check_table_dimensions(
            rows, header_rows, stub_columns, name, lineno,
            block_text, state_machine)
        table_head.extend(rows[:header_rows])
        table_body = rows[header_rows:]
        col_widths = get_column_widths(
            max_cols, name, options, lineno, block_text, state_machine)
        extend_short_rows_with_empty_cells(max_cols, (table_head, table_body))
    except SystemMessagePropagation, detail:
        return [detail.args[0]]
    except csv.Error, detail:
        error = state_machine.reporter.error(
            'Error with CSV data in "%s" directive:\n%s' % (name, detail),
            nodes.literal_block(block_text, block_text), line=lineno)
        return [error]
    table = (col_widths, table_head, table_body)
    table_node = state.build_table(table, content_offset, stub_columns)
    table_node['classes'] += options.get('class', [])
    if title:
        table_node.insert(0, title)
    return [table_node] + messages

csv_table.arguments = (0, 1, 1)
csv_table.options = {'header-rows': directives.nonnegative_int,
                     'stub-columns': directives.nonnegative_int,
                     'header': directives.unchanged,
                     'widths': directives.positive_int_list,
                     'file': directives.path,
                     'url': directives.uri,
                     'encoding': directives.encoding,
                     'class': directives.class_option,
                     # field delimiter char
                     'delim': directives.single_char_or_whitespace_or_unicode,
                     # treat whitespace after delimiter as significant
                     'keepspace': directives.flag,
                     # text field quote/unquote char:
                     'quote': directives.single_char_or_unicode,
                     # char used to escape delim & quote as-needed:
                     'escape': directives.single_char_or_unicode,}
csv_table.content = 1

def check_requirements(name, lineno, block_text, state_machine):
    if not csv:
        error = state_machine.reporter.error(
            'The "%s" directive is not compatible with this version of '
            'Python (%s).  Requires the "csv" module, new in Python 2.3.'
            % (name, sys.version.split()[0]),
            nodes.literal_block(block_text, block_text), line=lineno)
        raise SystemMessagePropagation(error)

def get_csv_data(name, options, content, lineno, block_text,
                 state, state_machine):
    """
    CSV data can come from the directive content, from an external file, or
    from a URL reference.
    """
    encoding = options.get('encoding', state.document.settings.input_encoding)
    if content:                         # CSV data is from directive content
        if options.has_key('file') or options.has_key('url'):
            error = state_machine.reporter.error(
                  '"%s" directive may not both specify an external file and '
                  'have content.' % name,
                  nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(error)
        source = content.source(0)
        csv_data = content
    elif options.has_key('file'):       # CSV data is from an external file
        if options.has_key('url'):
            error = state_machine.reporter.error(
                  'The "file" and "url" options may not be simultaneously '
                  'specified for the "%s" directive.' % name,
                  nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(error)
        source_dir = os.path.dirname(
            os.path.abspath(state.document.current_source))
        source = os.path.normpath(os.path.join(source_dir, options['file']))
        source = utils.relative_path(None, source)
        try:
            state.document.settings.record_dependencies.add(source)
            csv_file = io.FileInput(
                source_path=source, encoding=encoding,
                error_handler
                    =state.document.settings.input_encoding_error_handler,
                handle_io_errors=None)
            csv_data = csv_file.read().splitlines()
        except IOError, error:
            severe = state_machine.reporter.severe(
                  'Problems with "%s" directive path:\n%s.' % (name, error),
                  nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(severe)
    elif options.has_key('url'):        # CSV data is from a URL
        if not urllib2:
            severe = state_machine.reporter.severe(
                  'Problems with the "%s" directive and its "url" option: '
                  'unable to access the required functionality (from the '
                  '"urllib2" module).' % name,
                  nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(severe)
        source = options['url']
        try:
            csv_text = urllib2.urlopen(source).read()
        except (urllib2.URLError, IOError, OSError, ValueError), error:
            severe = state_machine.reporter.severe(
                  'Problems with "%s" directive URL "%s":\n%s.'
                  % (name, options['url'], error),
                  nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(severe)
        csv_file = io.StringInput(
            source=csv_text, source_path=source, encoding=encoding,
            error_handler=state.document.settings.input_encoding_error_handler)
        csv_data = csv_file.read().splitlines()
    else:
        error = state_machine.reporter.warning(
            'The "%s" directive requires content; none supplied.' % (name),
            nodes.literal_block(block_text, block_text), line=lineno)
        raise SystemMessagePropagation(error)
    return csv_data, source

def process_header_option(options, state_machine, lineno):
    source = state_machine.get_source(lineno - 1)
    table_head = []
    max_header_cols = 0
    if options.has_key('header'):       # separate table header in option
        rows, max_header_cols = parse_csv_data_into_rows(
            options['header'].split('\n'), HeaderDialect(), source, options)
        table_head.extend(rows)
    return table_head, max_header_cols

def parse_csv_data_into_rows(csv_data, dialect, source, options):
    # csv.py doesn't do Unicode; encode temporarily as UTF-8
    csv_reader = csv.reader([line.encode('utf-8') for line in csv_data],
                            dialect=dialect)
    rows = []
    max_cols = 0
    for row in csv_reader:
        row_data = []
        for cell in row:
            # decode UTF-8 back to Unicode
            cell_text = unicode(cell, 'utf-8')
            cell_data = (0, 0, 0, statemachine.StringList(
                cell_text.splitlines(), source=source))
            row_data.append(cell_data)
        rows.append(row_data)
        max_cols = max(max_cols, len(row))
    return rows, max_cols

def check_table_dimensions(rows, header_rows, stub_columns, name, lineno,
                           block_text, state_machine):
    if len(rows) < header_rows:
        error = state_machine.reporter.error(
            '%s header row(s) specified but only %s row(s) of data supplied '
            '("%s" directive).' % (header_rows, len(rows), name),
            nodes.literal_block(block_text, block_text), line=lineno)
        raise SystemMessagePropagation(error)
    if len(rows) == header_rows > 0:
        error = state_machine.reporter.error(
            'Insufficient data supplied (%s row(s)); no data remaining for '
            'table body, required by "%s" directive.' % (len(rows), name),
            nodes.literal_block(block_text, block_text), line=lineno)
        raise SystemMessagePropagation(error)
    for row in rows:
        if len(row) < stub_columns:
            error = state_machine.reporter.error(
                '%s stub column(s) specified but only %s columns(s) of data '
                'supplied ("%s" directive).' % (stub_columns, len(row), name),
                nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(error)
        if len(row) == stub_columns > 0:
            error = state_machine.reporter.error(
                'Insufficient data supplied (%s columns(s)); no data remaining '
                'for table body, required by "%s" directive.'
                % (len(row), name),
                nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(error)

def get_column_widths(max_cols, name, options, lineno, block_text,
                      state_machine):
    if options.has_key('widths'):
        col_widths = options['widths']
        if len(col_widths) != max_cols:
            error = state_machine.reporter.error(
              '"%s" widths do not match the number of columns in table (%s).'
              % (name, max_cols),
              nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(error)
    elif max_cols:
        col_widths = [100 / max_cols] * max_cols
    else:
        error = state_machine.reporter.error(
            'No table data detected in CSV file.',
            nodes.literal_block(block_text, block_text), line=lineno)
        raise SystemMessagePropagation(error)
    return col_widths

def extend_short_rows_with_empty_cells(columns, parts):
    for part in parts:
        for row in part:
            if len(row) < columns:
                row.extend([(0, 0, 0, [])] * (columns - len(row)))

def list_table(name, arguments, options, content, lineno,
               content_offset, block_text, state, state_machine):
    """
    Implement tables whose data is encoded as a uniform two-level bullet list.
    For further ideas, see
    http://docutils.sf.net/docs/dev/rst/alternatives.html#list-driven-tables
    """ 
    if not content:
        error = state_machine.reporter.error(
            'The "%s" directive is empty; content required.' % name,
            nodes.literal_block(block_text, block_text), line=lineno)
        return [error]
    title, messages = make_title(arguments, state, lineno)
    node = nodes.Element()          # anonymous container for parsing
    state.nested_parse(content, content_offset, node)
    try:
        num_cols, col_widths = check_list_content(
            node, name, options, content, lineno, block_text, state_machine)
        table_data = [[item.children for item in row_list[0]]
                      for row_list in node[0]]
        header_rows = options.get('header-rows', 0) # default 0
        stub_columns = options.get('stub-columns', 0) # default 0
        check_table_dimensions(
            table_data, header_rows, stub_columns, name, lineno,
            block_text, state_machine)
    except SystemMessagePropagation, detail:
        return [detail.args[0]]
    table_node = build_table_from_list(table_data, col_widths,
                                       header_rows, stub_columns)
    table_node['classes'] += options.get('class', [])
    if title:
        table_node.insert(0, title)
    return [table_node] + messages

list_table.arguments = (0, 1, 1)
list_table.options = {'header-rows': directives.nonnegative_int,
                      'stub-columns': directives.nonnegative_int,
                      'widths': directives.positive_int_list,
                      'class': directives.class_option}
list_table.content = 1

def check_list_content(node, name, options, content, lineno, block_text,
                       state_machine):
    if len(node) != 1 or not isinstance(node[0], nodes.bullet_list):
        error = state_machine.reporter.error(
            'Error parsing content block for the "%s" directive: '
            'exactly one bullet list expected.' % name,
            nodes.literal_block(block_text, block_text), line=lineno)
        raise SystemMessagePropagation(error)
    list_node = node[0]
    # Check for a uniform two-level bullet list:
    for item_index in range(len(list_node)):
        item = list_node[item_index]
        if len(item) != 1 or not isinstance(item[0], nodes.bullet_list):
            error = state_machine.reporter.error(
                'Error parsing content block for the "%s" directive: '
                'two-level bullet list expected, but row %s does not contain '
                'a second-level bullet list.' % (name, item_index + 1),
                nodes.literal_block(block_text, block_text), line=lineno)
            raise SystemMessagePropagation(error)
        elif item_index:
            # ATTN pychecker users: num_cols is guaranteed to be set in the
            # "else" clause below for item_index==0, before this branch is
            # triggered.
            if len(item[0]) != num_cols:
                error = state_machine.reporter.error(
                    'Error parsing content block for the "%s" directive: '
                    'uniform two-level bullet list expected, but row %s does '
                    'not contain the same number of items as row 1 (%s vs %s).'
                    % (name, item_index + 1, len(item[0]), num_cols),
                    nodes.literal_block(block_text, block_text), line=lineno)
                raise SystemMessagePropagation(error)
        else:
            num_cols = len(item[0])
    col_widths = get_column_widths(
        num_cols, name, options, lineno, block_text, state_machine)
    if len(col_widths) != num_cols:
        error = state_machine.reporter.error(
            'Error parsing "widths" option of the "%s" directive: '
            'number of columns does not match the table data (%s vs %s).'
            % (name, len(col_widths), num_cols),
            nodes.literal_block(block_text, block_text), line=lineno)
        raise SystemMessagePropagation(error)
    return num_cols, col_widths

def build_table_from_list(table_data, col_widths, header_rows, stub_columns):
    table = nodes.table()
    tgroup = nodes.tgroup(cols=len(col_widths))
    table += tgroup
    for col_width in col_widths:
        colspec = nodes.colspec(colwidth=col_width)
        if stub_columns:
            colspec.attributes['stub'] = 1
            stub_columns -= 1
        tgroup += colspec
    rows = []
    for row in table_data:
        row_node = nodes.row()
        for cell in row:
            entry = nodes.entry()
            entry += cell
            row_node += entry
        rows.append(row_node)
    if header_rows:
        thead = nodes.thead()
        thead.extend(rows[:header_rows])
        tgroup += thead
    tbody = nodes.tbody()
    tbody.extend(rows[header_rows:])
    tgroup += tbody
    return table
