1# $Id: misc.py 7487 2012-07-22 21:20:28Z milde $
2# Authors: David Goodger <goodger@python.org>; Dethe Elza
3# Copyright: This module has been placed in the public domain.
4
5"""Miscellaneous directives."""
6
7__docformat__ = 'reStructuredText'
8
9import sys
10import os.path
11import re
12import time
13from docutils import io, nodes, statemachine, utils
14from docutils.utils.error_reporting import SafeString, ErrorString
15from docutils.utils.error_reporting import locale_encoding
16from docutils.parsers.rst import Directive, convert_directive_function
17from docutils.parsers.rst import directives, roles, states
18from docutils.parsers.rst.directives.body import CodeBlock, NumberLines
19from docutils.parsers.rst.roles import set_classes
20from docutils.transforms import misc
21
22class Include(Directive):
23
24    """
25    Include content read from a separate source file.
26
27    Content may be parsed by the parser, or included as a literal
28    block.  The encoding of the included file can be specified.  Only
29    a part of the given file argument may be included by specifying
30    start and end line or text to match before and/or after the text
31    to be used.
32    """
33
34    required_arguments = 1
35    optional_arguments = 0
36    final_argument_whitespace = True
37    option_spec = {'literal': directives.flag,
38                   'code': directives.unchanged,
39                   'encoding': directives.encoding,
40                   'tab-width': int,
41                   'start-line': int,
42                   'end-line': int,
43                   'start-after': directives.unchanged_required,
44                   'end-before': directives.unchanged_required,
45                   # ignored except for 'literal' or 'code':
46                   'number-lines': directives.unchanged, # integer or None
47                   'class': directives.class_option,
48                   'name': directives.unchanged}
49
50    standard_include_path = os.path.join(os.path.dirname(states.__file__),
51                                         'include')
52
53    def run(self):
54        """Include a file as part of the content of this reST file."""
55        if not self.state.document.settings.file_insertion_enabled:
56            raise self.warning('"%s" directive disabled.' % self.name)
57        source = self.state_machine.input_lines.source(
58            self.lineno - self.state_machine.input_offset - 1)
59        source_dir = os.path.dirname(os.path.abspath(source))
60        path = directives.path(self.arguments[0])
61        if path.startswith('<') and path.endswith('>'):
62            path = os.path.join(self.standard_include_path, path[1:-1])
63        path = os.path.normpath(os.path.join(source_dir, path))
64        path = utils.relative_path(None, path)
65        path = nodes.reprunicode(path)
66        encoding = self.options.get(
67            'encoding', self.state.document.settings.input_encoding)
68        e_handler=self.state.document.settings.input_encoding_error_handler
69        tab_width = self.options.get(
70            'tab-width', self.state.document.settings.tab_width)
71        try:
72            self.state.document.settings.record_dependencies.add(path)
73            include_file = io.FileInput(source_path=path,
74                                        encoding=encoding,
75                                        error_handler=e_handler)
76        except UnicodeEncodeError, error:
77            raise self.severe(u'Problems with "%s" directive path:\n'
78                              'Cannot encode input file path "%s" '
79                              '(wrong locale?).' %
80                              (self.name, SafeString(path)))
81        except IOError, error:
82            raise self.severe(u'Problems with "%s" directive path:\n%s.' %
83                      (self.name, ErrorString(error)))
84        startline = self.options.get('start-line', None)
85        endline = self.options.get('end-line', None)
86        try:
87            if startline or (endline is not None):
88                lines = include_file.readlines()
89                rawtext = ''.join(lines[startline:endline])
90            else:
91                rawtext = include_file.read()
92        except UnicodeError, error:
93            raise self.severe(u'Problem with "%s" directive:\n%s' %
94                              (self.name, ErrorString(error)))
95        # start-after/end-before: no restrictions on newlines in match-text,
96        # and no restrictions on matching inside lines vs. line boundaries
97        after_text = self.options.get('start-after', None)
98        if after_text:
99            # skip content in rawtext before *and incl.* a matching text
100            after_index = rawtext.find(after_text)
101            if after_index < 0:
102                raise self.severe('Problem with "start-after" option of "%s" '
103                                  'directive:\nText not found.' % self.name)
104            rawtext = rawtext[after_index + len(after_text):]
105        before_text = self.options.get('end-before', None)
106        if before_text:
107            # skip content in rawtext after *and incl.* a matching text
108            before_index = rawtext.find(before_text)
109            if before_index < 0:
110                raise self.severe('Problem with "end-before" option of "%s" '
111                                  'directive:\nText not found.' % self.name)
112            rawtext = rawtext[:before_index]
113
114        include_lines = statemachine.string2lines(rawtext, tab_width,
115                                                  convert_whitespace=True)
116        if 'literal' in self.options:
117            # Convert tabs to spaces, if `tab_width` is positive.
118            if tab_width >= 0:
119                text = rawtext.expandtabs(tab_width)
120            else:
121                text = rawtext
122            literal_block = nodes.literal_block(rawtext, source=path,
123                                    classes=self.options.get('class', []))
124            literal_block.line = 1
125            self.add_name(literal_block)
126            if 'number-lines' in self.options:
127                try:
128                    startline = int(self.options['number-lines'] or 1)
129                except ValueError:
130                    raise self.error(':number-lines: with non-integer '
131                                     'start value')
132                endline = startline + len(include_lines)
133                if text.endswith('\n'):
134                    text = text[:-1]
135                tokens = NumberLines([([], text)], startline, endline)
136                for classes, value in tokens:
137                    if classes:
138                        literal_block += nodes.inline(value, value,
139                                                      classes=classes)
140                    else:
141                        literal_block += nodes.Text(value, value)
142            else:
143                literal_block += nodes.Text(text, text)
144            return [literal_block]
145        if 'code' in self.options:
146            self.options['source'] = path
147            codeblock = CodeBlock(self.name,
148                                  [self.options.pop('code')], # arguments
149                                  self.options,
150                                  include_lines, # content
151                                  self.lineno,
152                                  self.content_offset,
153                                  self.block_text,
154                                  self.state,
155                                  self.state_machine)
156            return codeblock.run()
157        self.state_machine.insert_input(include_lines, path)
158        return []
159
160
161class Raw(Directive):
162
163    """
164    Pass through content unchanged
165
166    Content is included in output based on type argument
167
168    Content may be included inline (content section of directive) or
169    imported from a file or url.
170    """
171
172    required_arguments = 1
173    optional_arguments = 0
174    final_argument_whitespace = True
175    option_spec = {'file': directives.path,
176                   'url': directives.uri,
177                   'encoding': directives.encoding}
178    has_content = True
179
180    def run(self):
181        if (not self.state.document.settings.raw_enabled
182            or (not self.state.document.settings.file_insertion_enabled
183                and ('file' in self.options
184                     or 'url' in self.options))):
185            raise self.warning('"%s" directive disabled.' % self.name)
186        attributes = {'format': ' '.join(self.arguments[0].lower().split())}
187        encoding = self.options.get(
188            'encoding', self.state.document.settings.input_encoding)
189        e_handler=self.state.document.settings.input_encoding_error_handler
190        if self.content:
191            if 'file' in self.options or 'url' in self.options:
192                raise self.error(
193                    '"%s" directive may not both specify an external file '
194                    'and have content.' % self.name)
195            text = '\n'.join(self.content)
196        elif 'file' in self.options:
197            if 'url' in self.options:
198                raise self.error(
199                    'The "file" and "url" options may not be simultaneously '
200                    'specified for the "%s" directive.' % self.name)
201            source_dir = os.path.dirname(
202                os.path.abspath(self.state.document.current_source))
203            path = os.path.normpath(os.path.join(source_dir,
204                                                 self.options['file']))
205            path = utils.relative_path(None, path)
206            try:
207                raw_file = io.FileInput(source_path=path,
208                                        encoding=encoding,
209                                        error_handler=e_handler)
210                # TODO: currently, raw input files are recorded as
211                # dependencies even if not used for the chosen output format.
212                self.state.document.settings.record_dependencies.add(path)
213            except IOError, error:
214                raise self.severe(u'Problems with "%s" directive path:\n%s.'
215                                  % (self.name, ErrorString(error)))
216            try:
217                text = raw_file.read()
218            except UnicodeError, error:
219                raise self.severe(u'Problem with "%s" directive:\n%s'
220                    % (self.name, ErrorString(error)))
221            attributes['source'] = path
222        elif 'url' in self.options:
223            source = self.options['url']
224            # Do not import urllib2 at the top of the module because
225            # it may fail due to broken SSL dependencies, and it takes
226            # about 0.15 seconds to load.
227            import urllib2
228            try:
229                raw_text = urllib2.urlopen(source).read()
230            except (urllib2.URLError, IOError, OSError), error:
231                raise self.severe(u'Problems with "%s" directive URL "%s":\n%s.'
232                    % (self.name, self.options['url'], ErrorString(error)))
233            raw_file = io.StringInput(source=raw_text, source_path=source,
234                                      encoding=encoding,
235                                      error_handler=e_handler)
236            try:
237                text = raw_file.read()
238            except UnicodeError, error:
239                raise self.severe(u'Problem with "%s" directive:\n%s'
240                                  % (self.name, ErrorString(error)))
241            attributes['source'] = source
242        else:
243            # This will always fail because there is no content.
244            self.assert_has_content()
245        raw_node = nodes.raw('', text, **attributes)
246        (raw_node.source,
247        raw_node.line) = self.state_machine.get_source_and_line(self.lineno)
248        return [raw_node]
249
250
251class Replace(Directive):
252
253    has_content = True
254
255    def run(self):
256        if not isinstance(self.state, states.SubstitutionDef):
257            raise self.error(
258                'Invalid context: the "%s" directive can only be used within '
259                'a substitution definition.' % self.name)
260        self.assert_has_content()
261        text = '\n'.join(self.content)
262        element = nodes.Element(text)
263        self.state.nested_parse(self.content, self.content_offset,
264                                element)
265        # element might contain [paragraph] + system_message(s)
266        node = None
267        messages = []
268        for elem in element:
269            if not node and isinstance(elem, nodes.paragraph):
270                node = elem
271            elif isinstance(elem, nodes.system_message):
272                elem['backrefs'] = []
273                messages.append(elem)
274            else:
275                return [
276                    self.state_machine.reporter.error(
277                        'Error in "%s" directive: may contain a single paragraph '
278                        'only.' % (self.name), line=self.lineno) ]
279        if node:
280            return messages + node.children
281        return messages
282
283class Unicode(Directive):
284
285    r"""
286    Convert Unicode character codes (numbers) to characters.  Codes may be
287    decimal numbers, hexadecimal numbers (prefixed by ``0x``, ``x``, ``\x``,
288    ``U+``, ``u``, or ``\u``; e.g. ``U+262E``), or XML-style numeric character
289    entities (e.g. ``&#x262E;``).  Text following ".." is a comment and is
290    ignored.  Spaces are ignored, and any other text remains as-is.
291    """
292
293    required_arguments = 1
294    optional_arguments = 0
295    final_argument_whitespace = True
296    option_spec = {'trim': directives.flag,
297                   'ltrim': directives.flag,
298                   'rtrim': directives.flag}
299
300    comment_pattern = re.compile(r'( |\n|^)\.\. ')
301
302    def run(self):
303        if not isinstance(self.state, states.SubstitutionDef):
304            raise self.error(
305                'Invalid context: the "%s" directive can only be used within '
306                'a substitution definition.' % self.name)
307        substitution_definition = self.state_machine.node
308        if 'trim' in self.options:
309            substitution_definition.attributes['ltrim'] = 1
310            substitution_definition.attributes['rtrim'] = 1
311        if 'ltrim' in self.options:
312            substitution_definition.attributes['ltrim'] = 1
313        if 'rtrim' in self.options:
314            substitution_definition.attributes['rtrim'] = 1
315        codes = self.comment_pattern.split(self.arguments[0])[0].split()
316        element = nodes.Element()
317        for code in codes:
318            try:
319                decoded = directives.unicode_code(code)
320            except ValueError, error:
321                raise self.error(u'Invalid character code: %s\n%s'
322                    % (code, ErrorString(error)))
323            element += nodes.Text(decoded)
324        return element.children
325
326
327class Class(Directive):
328
329    """
330    Set a "class" attribute on the directive content or the next element.
331    When applied to the next element, a "pending" element is inserted, and a
332    transform does the work later.
333    """
334
335    required_arguments = 1
336    optional_arguments = 0
337    final_argument_whitespace = True
338    has_content = True
339
340    def run(self):
341        try:
342            class_value = directives.class_option(self.arguments[0])
343        except ValueError:
344            raise self.error(
345                'Invalid class attribute value for "%s" directive: "%s".'
346                % (self.name, self.arguments[0]))
347        node_list = []
348        if self.content:
349            container = nodes.Element()
350            self.state.nested_parse(self.content, self.content_offset,
351                                    container)
352            for node in container:
353                node['classes'].extend(class_value)
354            node_list.extend(container.children)
355        else:
356            pending = nodes.pending(
357                misc.ClassAttribute,
358                {'class': class_value, 'directive': self.name},
359                self.block_text)
360            self.state_machine.document.note_pending(pending)
361            node_list.append(pending)
362        return node_list
363
364
365class Role(Directive):
366
367    has_content = True
368
369    argument_pattern = re.compile(r'(%s)\s*(\(\s*(%s)\s*\)\s*)?$'
370                                  % ((states.Inliner.simplename,) * 2))
371
372    def run(self):
373        """Dynamically create and register a custom interpreted text role."""
374        if self.content_offset > self.lineno or not self.content:
375            raise self.error('"%s" directive requires arguments on the first '
376                             'line.' % self.name)
377        args = self.content[0]
378        match = self.argument_pattern.match(args)
379        if not match:
380            raise self.error('"%s" directive arguments not valid role names: '
381                             '"%s".' % (self.name, args))
382        new_role_name = match.group(1)
383        base_role_name = match.group(3)
384        messages = []
385        if base_role_name:
386            base_role, messages = roles.role(
387                base_role_name, self.state_machine.language, self.lineno,
388                self.state.reporter)
389            if base_role is None:
390                error = self.state.reporter.error(
391                    'Unknown interpreted text role "%s".' % base_role_name,
392                    nodes.literal_block(self.block_text, self.block_text),
393                    line=self.lineno)
394                return messages + [error]
395        else:
396            base_role = roles.generic_custom_role
397        assert not hasattr(base_role, 'arguments'), (
398            'Supplemental directive arguments for "%s" directive not '
399            'supported (specified by "%r" role).' % (self.name, base_role))
400        try:
401            converted_role = convert_directive_function(base_role)
402            (arguments, options, content, content_offset) = (
403                self.state.parse_directive_block(
404                self.content[1:], self.content_offset, converted_role,
405                option_presets={}))
406        except states.MarkupError, detail:
407            error = self.state_machine.reporter.error(
408                'Error in "%s" directive:\n%s.' % (self.name, detail),
409                nodes.literal_block(self.block_text, self.block_text),
410                line=self.lineno)
411            return messages + [error]
412        if 'class' not in options:
413            try:
414                options['class'] = directives.class_option(new_role_name)
415            except ValueError, detail:
416                error = self.state_machine.reporter.error(
417                    u'Invalid argument for "%s" directive:\n%s.'
418                    % (self.name, SafeString(detail)), nodes.literal_block(
419                    self.block_text, self.block_text), line=self.lineno)
420                return messages + [error]
421        role = roles.CustomRole(new_role_name, base_role, options, content)
422        roles.register_local_role(new_role_name, role)
423        return messages
424
425
426class DefaultRole(Directive):
427
428    """Set the default interpreted text role."""
429
430    optional_arguments = 1
431    final_argument_whitespace = False
432
433    def run(self):
434        if not self.arguments:
435            if '' in roles._roles:
436                # restore the "default" default role
437                del roles._roles['']
438            return []
439        role_name = self.arguments[0]
440        role, messages = roles.role(role_name, self.state_machine.language,
441                                    self.lineno, self.state.reporter)
442        if role is None:
443            error = self.state.reporter.error(
444                'Unknown interpreted text role "%s".' % role_name,
445                nodes.literal_block(self.block_text, self.block_text),
446                line=self.lineno)
447            return messages + [error]
448        roles._roles[''] = role
449        # @@@ should this be local to the document, not the parser?
450        return messages
451
452
453class Title(Directive):
454
455    required_arguments = 1
456    optional_arguments = 0
457    final_argument_whitespace = True
458
459    def run(self):
460        self.state_machine.document['title'] = self.arguments[0]
461        return []
462
463
464class Date(Directive):
465
466    has_content = True
467
468    def run(self):
469        if not isinstance(self.state, states.SubstitutionDef):
470            raise self.error(
471                'Invalid context: the "%s" directive can only be used within '
472                'a substitution definition.' % self.name)
473        format_str = '\n'.join(self.content) or '%Y-%m-%d'
474        if sys.version_info< (3, 0):
475            try:
476                format_str = format_str.encode(locale_encoding or 'utf-8')
477            except UnicodeEncodeError:
478                raise self.warning(u'Cannot encode date format string '
479                    u'with locale encoding "%s".' % locale_encoding)
480        text = time.strftime(format_str)
481        if sys.version_info< (3, 0):
482            # `text` is a byte string that may contain non-ASCII characters:
483            try:
484                text = text.decode(locale_encoding or 'utf-8')
485            except UnicodeDecodeError:
486                text = text.decode(locale_encoding or 'utf-8', 'replace')
487                raise self.warning(u'Error decoding "%s"'
488                    u'with locale encoding "%s".' % (text, locale_encoding))
489        return [nodes.Text(text)]
490
491
492class TestDirective(Directive):
493
494    """This directive is useful only for testing purposes."""
495
496    optional_arguments = 1
497    final_argument_whitespace = True
498    option_spec = {'option': directives.unchanged_required}
499    has_content = True
500
501    def run(self):
502        if self.content:
503            text = '\n'.join(self.content)
504            info = self.state_machine.reporter.info(
505                'Directive processed. Type="%s", arguments=%r, options=%r, '
506                'content:' % (self.name, self.arguments, self.options),
507                nodes.literal_block(text, text), line=self.lineno)
508        else:
509            info = self.state_machine.reporter.info(
510                'Directive processed. Type="%s", arguments=%r, options=%r, '
511                'content: None' % (self.name, self.arguments, self.options),
512                line=self.lineno)
513        return [info]
514
515# Old-style, functional definition:
516#
517# def directive_test_function(name, arguments, options, content, lineno,
518#                             content_offset, block_text, state, state_machine):
519#     """This directive is useful only for testing purposes."""
520#     if content:
521#         text = '\n'.join(content)
522#         info = state_machine.reporter.info(
523#             'Directive processed. Type="%s", arguments=%r, options=%r, '
524#             'content:' % (name, arguments, options),
525#             nodes.literal_block(text, text), line=lineno)
526#     else:
527#         info = state_machine.reporter.info(
528#             'Directive processed. Type="%s", arguments=%r, options=%r, '
529#             'content: None' % (name, arguments, options), line=lineno)
530#     return [info]
531#
532# directive_test_function.arguments = (0, 1, 1)
533# directive_test_function.options = {'option': directives.unchanged_required}
534# directive_test_function.content = 1
535