Code

Ticket #16493: makemessages.py

File makemessages.py, 16.4 KB (added by raidsan@…, 3 years ago)
Line 
1import fnmatch
2import glob
3import os
4import re
5import sys
6from itertools import dropwhile
7from optparse import make_option
8from subprocess import PIPE, Popen
9
10from django.core.management.base import CommandError, NoArgsCommand
11from django.utils.text import get_text_list
12
13pythonize_re = re.compile(r'(?:^|\n)\s*//')
14plural_forms_re = re.compile(r'^(?P<value>"Plural-Forms.+?\\n")\s*$', re.MULTILINE | re.DOTALL)
15
16def normpath(filepath):
17    p = os.path.normpath(filepath)
18    if os.name=='nt':
19        p = p.replace("\\", "/")
20    return p
21
22def handle_extensions(extensions=('html',)):
23    """
24    organizes multiple extensions that are separated with commas or passed by
25    using --extension/-e multiple times.
26
27    for example: running 'django-admin makemessages -e js,txt -e xhtml -a'
28    would result in a extension list: ['.js', '.txt', '.xhtml']
29
30    >>> handle_extensions(['.html', 'html,js,py,py,py,.py', 'py,.py'])
31    ['.html', '.js']
32    >>> handle_extensions(['.html, txt,.tpl'])
33    ['.html', '.tpl', '.txt']
34    """
35    ext_list = []
36    for ext in extensions:
37        ext_list.extend(ext.replace(' ','').split(','))
38    for i, ext in enumerate(ext_list):
39        if not ext.startswith('.'):
40            ext_list[i] = '.%s' % ext_list[i]
41
42    # we don't want *.py files here because of the way non-*.py files
43    # are handled in make_messages() (they are copied to file.ext.py files to
44    # trick xgettext to parse them as Python files)
45    return set([x for x in ext_list if x != '.py'])
46
47def _popen(cmd):
48    """
49    Friendly wrapper around Popen for Windows
50    """
51    p = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE, close_fds=os.name != 'nt', universal_newlines=True)
52    return p.communicate()
53
54def walk(root, topdown=True, onerror=None, followlinks=False):
55    """
56    A version of os.walk that can follow symlinks for Python < 2.6
57    """
58    for dirpath, dirnames, filenames in os.walk(root, topdown, onerror):
59        yield (dirpath, dirnames, filenames)
60        if followlinks:
61            for d in dirnames:
62                p = os.path.join(dirpath, d)
63                if os.path.islink(p):
64                    for link_dirpath, link_dirnames, link_filenames in walk(p):
65                        yield (link_dirpath, link_dirnames, link_filenames)
66
67def is_ignored(path, ignore_patterns):
68    """
69    Helper function to check if the given path should be ignored or not.
70    """
71    for pattern in ignore_patterns:
72        if fnmatch.fnmatchcase(path, pattern):
73            return True
74    return False
75
76def find_files(root, ignore_patterns, verbosity, symlinks=False):
77    """
78    Helper function to get all files in the given root.
79    """
80    all_files = []
81    for (dirpath, dirnames, filenames) in walk(".", followlinks=symlinks):
82        for f in filenames:
83            norm_filepath = normpath(os.path.join(dirpath, f))
84            if is_ignored(norm_filepath, ignore_patterns):
85                if verbosity > 1:
86                    sys.stdout.write('ignoring file %s in %s\n' % (f, dirpath))
87            else:
88                all_files.extend([(dirpath, f)])
89    all_files.sort()
90    return all_files
91
92def copy_plural_forms(msgs, locale, domain, verbosity):
93    """
94    Copies plural forms header contents from a Django catalog of locale to
95    the msgs string, inserting it at the right place. msgs should be the
96    contents of a newly created .po file.
97    """
98    import django
99    django_dir = normpath(os.path.join(os.path.dirname(django.__file__)))
100    if domain == 'djangojs':
101        domains = ('djangojs', 'django')
102    else:
103        domains = ('django',)
104    for domain in domains:
105        django_po = normpath(os.path.join(django_dir, 'conf', 'locale', locale, 'LC_MESSAGES', '%s.po' % domain))
106        if os.path.exists(django_po):
107            m = plural_forms_re.search(open(django_po, 'rU').read())
108            if m:
109                if verbosity > 1:
110                    sys.stderr.write("copying plural forms: %s\n" % m.group('value'))
111                lines = []
112                seen = False
113                for line in msgs.split('\n'):
114                    if not line and not seen:
115                        line = '%s\n' % m.group('value')
116                        seen = True
117                    lines.append(line)
118                msgs = '\n'.join(lines)
119                break
120    return msgs
121
122
123def make_messages(locale=None, domain='django', verbosity='1', all=False,
124        extensions=None, symlinks=False, ignore_patterns=[], no_wrap=False,
125        no_obsolete=False):
126    """
127    Uses the locale directory from the Django SVN tree or an application/
128    project to process all
129    """
130    # Need to ensure that the i18n framework is enabled
131    from django.conf import settings
132    if settings.configured:
133        settings.USE_I18N = True
134    else:
135        settings.configure(USE_I18N = True)
136
137    from django.utils.translation import templatize
138
139    invoked_for_django = False
140    if os.path.isdir(os.path.join('conf', 'locale')):
141        localedir = normpath(os.path.abspath(os.path.join('conf', 'locale')))
142        invoked_for_django = True
143        # Ignoring all contrib apps
144        ignore_patterns += ['contrib/*']
145    elif os.path.isdir('locale'):
146        localedir = normpath(os.path.abspath('locale'))
147    else:
148        raise CommandError("This script should be run from the Django SVN tree or your project or app tree. If you did indeed run it from the SVN checkout or your project or application, maybe you are just missing the conf/locale (in the django tree) or locale (for project and application) directory? It is not created automatically, you have to create it by hand if you want to enable i18n for your project or application.")
149
150    if domain not in ('django', 'djangojs'):
151        raise CommandError("currently makemessages only supports domains 'django' and 'djangojs'")
152
153    if (locale is None and not all) or domain is None:
154        message = "Type '%s help %s' for usage information." % (os.path.basename(sys.argv[0]), sys.argv[1])
155        raise CommandError(message)
156
157    # We require gettext version 0.15 or newer.
158    output = _popen('xgettext --version')[0]
159    match = re.search(r'(?P<major>\d+)\.(?P<minor>\d+)', output)
160    if match:
161        xversion = (int(match.group('major')), int(match.group('minor')))
162        if xversion < (0, 15):
163            raise CommandError("Django internationalization requires GNU gettext 0.15 or newer. You are using version %s, please upgrade your gettext toolset." % match.group())
164
165    languages = []
166    if locale is not None:
167        languages.append(locale)
168    elif all:
169        locale_dirs = filter(os.path.isdir, glob.glob('%s/*' % localedir))
170        languages = [os.path.basename(l) for l in locale_dirs]
171
172    wrap = no_wrap and '--no-wrap' or ''
173
174    for locale in languages:
175        if verbosity > 0:
176            print "processing language", locale
177        basedir = normpath(os.path.join(localedir, locale, 'LC_MESSAGES'))
178        if not os.path.isdir(basedir):
179            os.makedirs(basedir)
180
181        pofile = normpath(os.path.join(basedir, '%s.po' % domain))
182        potfile = normpath(os.path.join(basedir, '%s.pot' % domain))
183
184        if os.path.exists(potfile):
185            os.unlink(potfile)
186
187        for dirpath, file in find_files(".", ignore_patterns, verbosity, symlinks=symlinks):
188            file_base, file_ext = os.path.splitext(file)
189            if domain == 'djangojs' and file_ext in extensions:
190                if verbosity > 1:
191                    sys.stdout.write('processing file %s in %s\n' % (file, dirpath))
192                src = open(os.path.join(dirpath, file), "rU").read()
193                src = pythonize_re.sub('\n#', src)
194                thefile = '%s.py' % file
195                f = open(os.path.join(dirpath, thefile), "w")
196                try:
197                    f.write(src)
198                finally:
199                    f.close()
200                cmd = (
201                    'xgettext -d %s -L Perl %s --keyword=gettext_noop '
202                    '--keyword=gettext_lazy --keyword=ngettext_lazy:1,2 '
203                    '--keyword=pgettext:1c,2 --keyword=npgettext:1c,2,3 '
204                    '--from-code UTF-8 --add-comments=Translators -o - "%s"' % (
205                        domain, wrap, normpath(os.path.join(dirpath, thefile))
206                    )
207                )
208                msgs, errors = _popen(cmd)
209                if errors:
210                    os.unlink(os.path.join(dirpath, thefile))
211                    if os.path.exists(potfile):
212                        os.unlink(potfile)
213                    raise CommandError(
214                        "errors happened while running xgettext on %s\n%s" %
215                        (file, errors))
216                if msgs:
217                    old = '#: ' + normpath(os.path.join(dirpath, thefile)[2:])
218                    new = '#: ' + normpath(os.path.join(dirpath, file)[2:])
219                    msgs = msgs.replace(old, new)
220                    if os.path.exists(potfile):
221                        # Strip the header
222                        msgs = '\n'.join(dropwhile(len, msgs.split('\n')))
223                    else:
224                        msgs = msgs.replace('charset=CHARSET', 'charset=UTF-8')
225                    f = open(potfile, 'ab')
226                    try:
227                        f.write(msgs)
228                    finally:
229                        f.close()
230                os.unlink(os.path.join(dirpath, thefile))
231            elif domain == 'django' and (file_ext == '.py' or file_ext in extensions):
232                thefile = file
233                orig_file = normpath(os.path.join(dirpath, file))
234                if file_ext in extensions:
235                    src = open(orig_file, "rU").read()
236                    thefile = '%s.py' % file
237                    f = open(os.path.join(dirpath, thefile), "w")
238                    try:
239                        f.write(templatize(src, orig_file[2:]))
240                    finally:
241                        f.close()
242                if verbosity > 1:
243                    sys.stdout.write('processing file %s in %s\n' % (file, dirpath))
244                cmd = (
245                    'xgettext -d %s -L Python %s --keyword=gettext_noop '
246                    '--keyword=gettext_lazy --keyword=ngettext_lazy:1,2 '
247                    '--keyword=ugettext_noop --keyword=ugettext_lazy '
248                    '--keyword=ungettext_lazy:1,2 --keyword=pgettext:1c,2 '
249                    '--keyword=npgettext:1c,2,3 --keyword=pgettext_lazy:1c,2 '
250                    '--keyword=npgettext_lazy:1c,2,3 --from-code UTF-8 '
251                    '--add-comments=Translators -o - "%s"' % (
252                        domain, wrap, normpath(os.path.join(dirpath, thefile)))
253                )
254                msgs, errors = _popen(cmd)
255                if errors:
256                    if thefile != file:
257                        os.unlink(os.path.join(dirpath, thefile))
258                    if os.path.exists(potfile):
259                        os.unlink(potfile)
260                    raise CommandError(
261                        "errors happened while running xgettext on %s\n%s" %
262                        (file, errors))
263                if msgs:
264                    if thefile != file:
265                        old = '#: ' + normpath(os.path.join(dirpath, thefile)[2:])
266                        new = '#: ' + normpath(orig_file[2:])
267                        msgs = msgs.replace(old, new)
268                    if os.path.exists(potfile):
269                        # Strip the header
270                        msgs = '\n'.join(dropwhile(len, msgs.split('\n')))
271                    else:
272                        msgs = msgs.replace('charset=CHARSET', 'charset=UTF-8')
273                    f = open(potfile, 'ab')
274                    try:
275                        f.write(msgs)
276                    finally:
277                        f.close()
278                if thefile != file:
279                    os.unlink(os.path.join(dirpath, thefile))
280
281        if os.path.exists(potfile):
282            msgs, errors = _popen('msguniq %s --to-code=utf-8 "%s"' %
283                                  (wrap, potfile))
284            if errors:
285                os.unlink(potfile)
286                raise CommandError(
287                    "errors happened while running msguniq\n%s" % errors)
288            if os.path.exists(pofile):
289                f = open(potfile, 'w')
290                try:
291                    f.write(msgs)
292                finally:
293                    f.close()
294                msgs, errors = _popen('msgmerge %s -q "%s" "%s"' %
295                                      (wrap, pofile, potfile))
296                if errors:
297                    os.unlink(potfile)
298                    raise CommandError(
299                        "errors happened while running msgmerge\n%s" % errors)
300            elif not invoked_for_django:
301                msgs = copy_plural_forms(msgs, locale, domain, verbosity)
302            msgs = msgs.replace(
303                "#. #-#-#-#-#  %s.pot (PACKAGE VERSION)  #-#-#-#-#\n" % domain, "")
304            f = open(pofile, 'wb')
305            try:
306                f.write(msgs)
307            finally:
308                f.close()
309            os.unlink(potfile)
310            if no_obsolete:
311                msgs, errors = _popen('msgattrib %s -o "%s" --no-obsolete "%s"' %
312                                      (wrap, pofile, pofile))
313                if errors:
314                    raise CommandError(
315                        "errors happened while running msgattrib\n%s" % errors)
316
317
318class Command(NoArgsCommand):
319    option_list = NoArgsCommand.option_list + (
320        make_option('--locale', '-l', default=None, dest='locale',
321            help='Creates or updates the message files for the given locale (e.g. pt_BR).'),
322        make_option('--domain', '-d', default='django', dest='domain',
323            help='The domain of the message files (default: "django").'),
324        make_option('--all', '-a', action='store_true', dest='all',
325            default=False, help='Updates the message files for all existing locales.'),
326        make_option('--extension', '-e', dest='extensions',
327            help='The file extension(s) to examine (default: ".html", separate multiple extensions with commas, or use -e multiple times)',
328            action='append'),
329        make_option('--symlinks', '-s', action='store_true', dest='symlinks',
330            default=False, help='Follows symlinks to directories when examining source code and templates for translation strings.'),
331        make_option('--ignore', '-i', action='append', dest='ignore_patterns',
332            default=[], metavar='PATTERN', help='Ignore files or directories matching this glob-style pattern. Use multiple times to ignore more.'),
333        make_option('--no-default-ignore', action='store_false', dest='use_default_ignore_patterns',
334            default=True, help="Don't ignore the common glob-style patterns 'CVS', '.*' and '*~'."),
335        make_option('--no-wrap', action='store_true', dest='no_wrap',
336            default=False, help="Don't break long message lines into several lines"),
337        make_option('--no-obsolete', action='store_true', dest='no_obsolete',
338            default=False, help="Remove obsolete message strings"),
339    )
340    help = ( "Runs over the entire source tree of the current directory and "
341"pulls out all strings marked for translation. It creates (or updates) a message "
342"file in the conf/locale (in the django tree) or locale (for projects and "
343"applications) directory.\n\nYou must run this command with one of either the "
344"--locale or --all options.")
345
346    requires_model_validation = False
347    can_import_settings = False
348
349    def handle_noargs(self, *args, **options):
350        locale = options.get('locale')
351        domain = options.get('domain')
352        verbosity = int(options.get('verbosity'))
353        process_all = options.get('all')
354        extensions = options.get('extensions')
355        symlinks = options.get('symlinks')
356        ignore_patterns = options.get('ignore_patterns')
357        if options.get('use_default_ignore_patterns'):
358            ignore_patterns += ['CVS', '.*', '*~']
359        ignore_patterns = list(set(ignore_patterns))
360        no_wrap = options.get('no_wrap')
361        no_obsolete = options.get('no_obsolete')
362        if domain == 'djangojs':
363            extensions = handle_extensions(extensions or ['js'])
364        else:
365            extensions = handle_extensions(extensions or ['html'])
366
367        if verbosity > 1:
368            sys.stdout.write('examining files with the extensions: %s\n'
369                             % get_text_list(list(extensions), 'and'))
370
371        make_messages(locale, domain, verbosity, process_all, extensions, symlinks, ignore_patterns, no_wrap, no_obsolete)