1
# Copyright (C) 2011 Canonical Ltd
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
# The normalize function is taken from pygettext which is distributed
18
# with Python under the Python License, which is GPL compatible.
20
"""Extract docstrings from Bazaar commands.
22
This module only handles breezy objects that use strings not directly wrapped
23
by a gettext() call. To generate a complete translation template file, this
24
output needs to be combined with that of xgettext or a similar command for
25
extracting those strings, as is done in the bzr Makefile. Sorting the output
26
is also left to that stage of the process.
29
from __future__ import absolute_import
36
commands as _mod_commands,
40
plugin as _mod_plugin,
42
from .sixish import PY3
47
from .i18n import gettext
51
s = (s.replace('\\', '\\\\')
61
# This converts the various Python string types into a format that
62
# is appropriate for .po files, namely much closer to C style.
65
s = '"' + _escape(s) + '"'
69
lines[-1] = lines[-1] + '\n'
71
s = '""\n"' + lineterm.join(map(_escape, lines)) + '"'
75
def _parse_source(source_text, filename='<unknown>'):
76
"""Get object to lineno mappings from given source_text"""
80
for node in ast.walk(ast.parse(source_text, filename)):
81
# TODO: worry about duplicates?
82
if isinstance(node, ast.ClassDef):
83
# TODO: worry about nesting?
84
cls_to_lineno[node.name] = node.lineno
85
elif isinstance(node, ast.Str):
86
# Python AST gives location of string literal as the line the
87
# string terminates on. It's more useful to have the line the
88
# string begins on. Unfortunately, counting back newlines is
89
# only an approximation as the AST is ignorant of escaping.
90
str_to_lineno[node.s] = node.lineno - node.s.count('\n')
91
return cls_to_lineno, str_to_lineno
94
class _ModuleContext(object):
95
"""Record of the location within a source tree"""
97
def __init__(self, path, lineno=1, _source_info=None):
100
if _source_info is not None:
101
self._cls_to_lineno, self._str_to_lineno = _source_info
104
def from_module(cls, module):
105
"""Get new context from module object and parse source for linenos"""
106
sourcepath = inspect.getsourcefile(module)
107
# TODO: fix this to do the right thing rather than rely on cwd
108
relpath = os.path.relpath(sourcepath)
110
_source_info=_parse_source("".join(inspect.findsource(module)[0]), module.__file__))
112
def from_class(self, cls):
113
"""Get new context with same details but lineno of class in source"""
115
lineno = self._cls_to_lineno[cls.__name__]
116
except (AttributeError, KeyError):
117
mutter("Definition of %r not found in %r", cls, self.path)
119
return self.__class__(self.path, lineno,
120
(self._cls_to_lineno, self._str_to_lineno))
122
def from_string(self, string):
123
"""Get new context with same details but lineno of string in source"""
125
lineno = self._str_to_lineno[string]
126
except (AttributeError, KeyError):
127
mutter("String %r not found in %r", string[:20], self.path)
129
return self.__class__(self.path, lineno,
130
(self._cls_to_lineno, self._str_to_lineno))
133
class _PotExporter(object):
134
"""Write message details to output stream in .pot file format"""
136
def __init__(self, outf, include_duplicates=False):
138
if include_duplicates:
142
self._module_contexts = {}
144
def poentry(self, path, lineno, s, comment=None):
145
if self._msgids is not None:
146
if s in self._msgids:
152
comment = "# %s\n" % comment
153
mutter("Exporting msg %r at line %d in %r", s[:20], lineno, path)
155
"#: {path}:{lineno}\n"
160
path=path, lineno=lineno, comment=comment, msg=_normalize(s)))
162
line = line.decode('utf-8')
163
self.outf.write(line)
165
def poentry_in_context(self, context, string, comment=None):
166
context = context.from_string(string)
167
self.poentry(context.path, context.lineno, string, comment)
169
def poentry_per_paragraph(self, path, lineno, msgid, include=None):
170
# TODO: How to split long help?
171
paragraphs = msgid.split('\n\n')
172
if include is not None:
173
paragraphs = filter(include, paragraphs)
175
self.poentry(path, lineno, p)
176
lineno += p.count('\n') + 2
178
def get_context(self, obj):
179
module = inspect.getmodule(obj)
181
context = self._module_contexts[module.__name__]
183
context = _ModuleContext.from_module(module)
184
self._module_contexts[module.__name__] = context
185
if inspect.isclass(obj):
186
context = context.from_class(obj)
190
def _write_option(exporter, context, opt, note):
191
if getattr(opt, 'hidden', False):
194
if getattr(opt, 'title', None):
195
exporter.poentry_in_context(context, opt.title,
196
"title of {name!r} {what}".format(name=optname, what=note))
197
for name, _, _, helptxt in opt.iter_switches():
199
if opt.is_hidden(name):
201
name = "=".join([optname, name])
203
exporter.poentry_in_context(context, helptxt,
204
"help of {name!r} {what}".format(name=name, what=note))
207
def _standard_options(exporter):
208
OPTIONS = option.Option.OPTIONS
209
context = exporter.get_context(option)
210
for name in sorted(OPTIONS):
212
_write_option(exporter, context.from_string(name), opt, "option")
215
def _command_options(exporter, context, cmd):
216
note = "option of {0!r} command".format(cmd.name())
217
for opt in cmd.takes_options:
218
# String values in Command option lists are for global options
219
if not isinstance(opt, str):
220
_write_option(exporter, context, opt, note)
223
def _write_command_help(exporter, cmd):
224
context = exporter.get_context(cmd.__class__)
226
dcontext = context.from_string(rawdoc)
227
doc = inspect.cleandoc(rawdoc)
229
def exclude_usage(p):
230
# ':Usage:' has special meaning in help topics.
231
# This is usage example of command and should not be translated.
232
if p.splitlines()[0] != ':Usage:':
235
exporter.poentry_per_paragraph(dcontext.path, dcontext.lineno, doc,
237
_command_options(exporter, context, cmd)
240
def _command_helps(exporter, plugin_name=None):
241
"""Extract docstrings from path.
243
This respects the Bazaar cmdtable/table convention and will
244
only extract docstrings from functions mentioned in these tables.
248
for cmd_name in _mod_commands.builtin_command_names():
249
command = _mod_commands.get_cmd_object(cmd_name, False)
252
if plugin_name is not None:
253
# only export builtins if we are not exporting plugin commands
255
note(gettext("Exporting messages from builtin command: %s"), cmd_name)
256
_write_command_help(exporter, command)
258
plugins = _mod_plugin.plugins()
259
if plugin_name is not None and plugin_name not in plugins:
260
raise errors.BzrError(gettext('Plugin %s is not loaded' % plugin_name))
262
name for name in plugins
263
if plugins[name].path().startswith(breezy.__path__[0]))
265
for cmd_name in _mod_commands.plugin_command_names():
266
command = _mod_commands.get_cmd_object(cmd_name, False)
269
if plugin_name is not None and command.plugin_name() != plugin_name:
270
# if we are exporting plugin commands, skip plugins we have not
273
if plugin_name is None and command.plugin_name() not in core_plugins:
274
# skip non-core plugins
275
# TODO: Support extracting from third party plugins.
277
note(gettext("Exporting messages from plugin command: {0} in {1}").format(
278
cmd_name, command.plugin_name()))
279
_write_command_help(exporter, command)
282
def _error_messages(exporter):
283
"""Extract fmt string from breezy.errors."""
284
context = exporter.get_context(errors)
285
base_klass = errors.BzrError
286
for name in dir(errors):
287
klass = getattr(errors, name)
288
if not inspect.isclass(klass):
290
if not issubclass(klass, base_klass):
292
if klass is base_klass:
294
if klass.internal_error:
296
fmt = getattr(klass, "_fmt", None)
298
note(gettext("Exporting message from error: %s"), name)
299
exporter.poentry_in_context(context, fmt)
302
def _help_topics(exporter):
303
topic_registry = help_topics.topic_registry
304
for key in topic_registry.keys():
305
doc = topic_registry.get(key)
306
if isinstance(doc, str):
307
exporter.poentry_per_paragraph(
308
'dummy/help_topics/' + key + '/detail.txt',
310
elif callable(doc): # help topics from files
311
exporter.poentry_per_paragraph(
312
'en/help_topics/' + key + '.txt',
314
summary = topic_registry.get_summary(key)
315
if summary is not None:
316
exporter.poentry('dummy/help_topics/' + key + '/summary.txt',
320
def export_pot(outf, plugin=None, include_duplicates=False):
321
exporter = _PotExporter(outf, include_duplicates)
323
_standard_options(exporter)
324
_command_helps(exporter)
325
_error_messages(exporter)
326
_help_topics(exporter)
328
_command_helps(exporter, plugin)