1#!/usr/bin/env python
2
3# $Id: buildhtml.py 8643 2021-03-26 13:51:21Z milde $
4# Author: David Goodger <goodger@python.org>
5# Copyright: This module has been placed in the public domain.
6
7"""
8Generates .html from all the .txt files in a directory.
9
10Ordinary .txt files are understood to be standalone reStructuredText.
11Files named ``pep-*.txt`` are interpreted as reStructuredText PEPs.
12"""
13# Once PySource is here, build .html from .py as well.
14
15__docformat__ = 'reStructuredText'
16
17
18try:
19    import locale
20    locale.setlocale(locale.LC_ALL, '')
21except:
22    pass
23
24import sys
25import os
26import os.path
27import copy
28from fnmatch import fnmatch
29import docutils
30from docutils import ApplicationError
31from docutils import core, frontend, utils
32from docutils.utils.error_reporting import ErrorOutput, ErrorString
33from docutils.parsers import rst
34from docutils.readers import standalone, pep
35from docutils.writers import html4css1, html5_polyglot, pep_html
36
37
38usage = '%prog [options] [<directory> ...]'
39description = ('Generates .html from all the reStructuredText .txt files '
40               '(including PEPs) in each <directory> '
41               '(default is the current directory).')
42
43
44class SettingsSpec(docutils.SettingsSpec):
45
46    """
47    Runtime settings & command-line options for the front end.
48    """
49
50    prune_default = ['.hg', '.bzr', '.git', '.svn', 'CVS']
51
52    # Can't be included in OptionParser below because we don't want to
53    # override the base class.
54    settings_spec = (
55        'Build-HTML Options',
56        None,
57        (('Recursively scan subdirectories for files to process.  This is '
58          'the default.',
59          ['--recurse'],
60          {'action': 'store_true', 'default': 1,
61           'validator': frontend.validate_boolean}),
62         ('Do not scan subdirectories for files to process.',
63          ['--local'], {'dest': 'recurse', 'action': 'store_false'}),
64         ('Do not process files in <directory> (shell globbing patterns, '
65          'separated by colons).  This option may be used '
66          'more than once to specify multiple directories.  Default: "%s".'
67          % ':'.join(prune_default),
68          ['--prune'],
69          {'metavar': '<directory>', 'action': 'append',
70           'validator': frontend.validate_colon_separated_string_list,
71           'default': prune_default,}),
72         ('Recursively ignore files matching any of the given '
73          'wildcard (shell globbing) patterns (separated by colons).',
74          ['--ignore'],
75          {'metavar': '<patterns>', 'action': 'append',
76           'default': [],
77           'validator': frontend.validate_colon_separated_string_list}),
78         ('Docutils writer, one of "html", "html4", "html5". '
79          'Default: "html" (use Docutils\' default HTML writer).',
80          ['--writer'],
81          {'metavar': '<writer>',
82           'choices': ['html', 'html4', 'html5'],
83           'default': 'html'}),
84         ('Obsoleted by "--writer".',
85          ['--html-writer'],
86          {'dest': 'writer',
87           'metavar': '<writer>',
88           'choices': ['html', 'html4', 'html5'],}),
89         ('Work silently (no progress messages).  Independent of "--quiet".',
90          ['--silent'],
91          {'action': 'store_true', 'validator': frontend.validate_boolean}),
92         ('Do not process files, show files that would be processed.',
93          ['--dry-run'],
94          {'action': 'store_true', 'validator': frontend.validate_boolean}),))
95
96    relative_path_settings = ('prune',)
97    config_section = 'buildhtml application'
98    config_section_dependencies = ('applications',)
99
100
101class OptionParser(frontend.OptionParser):
102
103    """
104    Command-line option processing for the ``buildhtml.py`` front end.
105    """
106
107    def check_values(self, values, args):
108        frontend.OptionParser.check_values(self, values, args)
109        values._source = None
110        return values
111
112    def check_args(self, args):
113        source = destination = None
114        if args:
115            self.values._directories = args
116        else:
117            self.values._directories = [os.getcwd()]
118        return source, destination
119
120
121class Struct(object):
122
123    """Stores data attributes for dotted-attribute access."""
124
125    def __init__(self, **keywordargs):
126        self.__dict__.update(keywordargs)
127
128
129class Builder(object):
130
131    def __init__(self):
132        self.publishers = {
133            '': Struct(components=(pep.Reader, rst.Parser, pep_html.Writer,
134                                   SettingsSpec)),
135            'html4': Struct(components=(rst.Parser, standalone.Reader,
136                                       html4css1.Writer, SettingsSpec),
137                           reader_name='standalone',
138                           writer_name='html4'),
139            'html5': Struct(components=(rst.Parser, standalone.Reader,
140                                       html5_polyglot.Writer, SettingsSpec),
141                           reader_name='standalone',
142                           writer_name='html5'),
143            'PEPs': Struct(components=(rst.Parser, pep.Reader,
144                                       pep_html.Writer, SettingsSpec),
145                           reader_name='pep',
146                           writer_name='pep_html')}
147        """Publisher-specific settings.  Key '' is for the front-end script
148        itself.  ``self.publishers[''].components`` must contain a superset of
149        all components used by individual publishers."""
150
151        self.setup_publishers()
152        # default html writer (may change to html5 some time):
153        self.publishers['html'] = self.publishers['html4']
154
155    def setup_publishers(self):
156        """
157        Manage configurations for individual publishers.
158
159        Each publisher (combination of parser, reader, and writer) may have
160        its own configuration defaults, which must be kept separate from those
161        of the other publishers.  Setting defaults are combined with the
162        config file settings and command-line options by
163        `self.get_settings()`.
164        """
165        for name, publisher in self.publishers.items():
166            option_parser = OptionParser(
167                components=publisher.components, read_config_files=1,
168                usage=usage, description=description)
169            publisher.option_parser = option_parser
170            publisher.setting_defaults = option_parser.get_default_values()
171            frontend.make_paths_absolute(publisher.setting_defaults.__dict__,
172                                         option_parser.relative_path_settings)
173            publisher.config_settings = (
174                option_parser.get_standard_config_settings())
175        self.settings_spec = self.publishers[''].option_parser.parse_args(
176            values=frontend.Values())   # no defaults; just the cmdline opts
177        self.initial_settings = self.get_settings('')
178
179    def get_settings(self, publisher_name, directory=None):
180        """
181        Return a settings object, from multiple sources.
182
183        Copy the setting defaults, overlay the startup config file settings,
184        then the local config file settings, then the command-line options.
185        Assumes the current directory has been set.
186        """
187        publisher = self.publishers[publisher_name]
188        settings = frontend.Values(publisher.setting_defaults.__dict__)
189        settings.update(publisher.config_settings, publisher.option_parser)
190        if directory:
191            local_config = publisher.option_parser.get_config_file_settings(
192                os.path.join(directory, 'docutils.conf'))
193            frontend.make_paths_absolute(
194                local_config, publisher.option_parser.relative_path_settings,
195                directory)
196            settings.update(local_config, publisher.option_parser)
197        settings.update(self.settings_spec.__dict__, publisher.option_parser)
198        return settings
199
200    def run(self, directory=None, recurse=1):
201        recurse = recurse and self.initial_settings.recurse
202        if directory:
203            self.directories = [directory]
204        elif self.settings_spec._directories:
205            self.directories = self.settings_spec._directories
206        else:
207            self.directories = [os.getcwd()]
208        for directory in self.directories:
209            for root, dirs, files in os.walk(directory):
210                # os.walk by default this recurses down the tree,
211                # influence by modifying dirs.
212                if not recurse:
213                    del dirs[:]
214                self.visit(root, files, dirs)
215
216    def visit(self, directory, names, subdirectories):
217        settings = self.get_settings('', directory)
218        errout = ErrorOutput(encoding=settings.error_encoding)
219        if settings.prune and (os.path.abspath(directory) in settings.prune):
220            errout.write('/// ...Skipping directory (pruned): %s\n' %
221                         directory)
222            sys.stderr.flush()
223            del subdirectories[:]
224            return
225        if not self.initial_settings.silent:
226            errout.write('/// Processing directory: %s\n' % directory)
227            sys.stderr.flush()
228        # settings.ignore grows many duplicate entries as we recurse
229        # if we add patterns in config files or on the command line.
230        for pattern in utils.uniq(settings.ignore):
231            for i in range(len(names) - 1, -1, -1):
232                if fnmatch(names[i], pattern):
233                    # Modify in place!
234                    del names[i]
235        for name in names:
236            if name.endswith('.txt'):
237                self.process_txt(directory, name)
238
239    def process_txt(self, directory, name):
240        if name.startswith('pep-'):
241            publisher = 'PEPs'
242        else:
243            publisher = self.initial_settings.writer
244        settings = self.get_settings(publisher, directory)
245        errout = ErrorOutput(encoding=settings.error_encoding)
246        pub_struct = self.publishers[publisher]
247        settings._source = os.path.normpath(os.path.join(directory, name))
248        settings._destination = settings._source[:-4]+'.html'
249        if not self.initial_settings.silent:
250            errout.write('    ::: Processing: %s\n' % name)
251            sys.stderr.flush()
252        try:
253            if not settings.dry_run:
254                core.publish_file(source_path=settings._source,
255                              destination_path=settings._destination,
256                              reader_name=pub_struct.reader_name,
257                              parser_name='restructuredtext',
258                              writer_name=pub_struct.writer_name,
259                              settings=settings)
260        except ApplicationError:
261            error = sys.exc_info()[1]  # get exception in Python 3.x
262            errout.write('        %s\n' % ErrorString(error))
263
264
265if __name__ == "__main__":
266    Builder().run()
267