Skip to content

Commit

Permalink
Enabled makemessages to support several translation directories
Browse files Browse the repository at this point in the history
Thanks Rémy Hubscher, Ramiro Morales, Unai Zalakain and
Tim Graham for the reviews.
Also fixes #16084.
  • Loading branch information
claudep committed Nov 30, 2013
1 parent 9af7e18 commit 50a8ab7
Show file tree
Hide file tree
Showing 13 changed files with 173 additions and 81 deletions.
158 changes: 96 additions & 62 deletions django/core/management/commands/makemessages.py
Expand Up @@ -29,25 +29,28 @@ def check_programs(*programs):


@total_ordering @total_ordering
class TranslatableFile(object): class TranslatableFile(object):
def __init__(self, dirpath, file_name): def __init__(self, dirpath, file_name, locale_dir):
self.file = file_name self.file = file_name
self.dirpath = dirpath self.dirpath = dirpath
self.locale_dir = locale_dir


def __repr__(self): def __repr__(self):
return "<TranslatableFile: %s>" % os.sep.join([self.dirpath, self.file]) return "<TranslatableFile: %s>" % os.sep.join([self.dirpath, self.file])


def __eq__(self, other): def __eq__(self, other):
return self.dirpath == other.dirpath and self.file == other.file return self.path == other.path


def __lt__(self, other): def __lt__(self, other):
if self.dirpath == other.dirpath: return self.path < other.path
return self.file < other.file
return self.dirpath < other.dirpath


def process(self, command, potfile, domain, keep_pot=False): @property
def path(self):
return os.path.join(self.dirpath, self.file)

def process(self, command, domain):
""" """
Extract translatable literals from self.file for :param domain: Extract translatable literals from self.file for :param domain:,
creating or updating the :param potfile: POT file. creating or updating the POT file.
Uses the xgettext GNU gettext utility. Uses the xgettext GNU gettext utility.
""" """
Expand Down Expand Up @@ -127,15 +130,15 @@ def process(self, command, potfile, domain, keep_pot=False):
if status != STATUS_OK: if status != STATUS_OK:
if is_templatized: if is_templatized:
os.unlink(work_file) os.unlink(work_file)
if not keep_pot and os.path.exists(potfile):
os.unlink(potfile)
raise CommandError( raise CommandError(
"errors happened while running xgettext on %s\n%s" % "errors happened while running xgettext on %s\n%s" %
(self.file, errors)) (self.file, errors))
elif command.verbosity > 0: elif command.verbosity > 0:
# Print warnings # Print warnings
command.stdout.write(errors) command.stdout.write(errors)
if msgs: if msgs:
# Write/append messages to pot file
potfile = os.path.join(self.locale_dir, '%s.pot' % str(domain))
if is_templatized: if is_templatized:
# Remove '.py' suffix # Remove '.py' suffix
if os.name == 'nt': if os.name == 'nt':
Expand All @@ -147,6 +150,7 @@ def process(self, command, potfile, domain, keep_pot=False):
new = '#: ' + orig_file[2:] new = '#: ' + orig_file[2:]
msgs = msgs.replace(old, new) msgs = msgs.replace(old, new)
write_pot_file(potfile, msgs) write_pot_file(potfile, msgs)

if is_templatized: if is_templatized:
os.unlink(work_file) os.unlink(work_file)


Expand Down Expand Up @@ -242,64 +246,94 @@ def handle_noargs(self, *args, **options):
% get_text_list(list(self.extensions), 'and')) % get_text_list(list(self.extensions), 'and'))


self.invoked_for_django = False self.invoked_for_django = False
self.locale_paths = []
self.default_locale_path = None
if os.path.isdir(os.path.join('conf', 'locale')): if os.path.isdir(os.path.join('conf', 'locale')):
localedir = os.path.abspath(os.path.join('conf', 'locale')) self.locale_paths = [os.path.abspath(os.path.join('conf', 'locale'))]
self.default_locale_path = self.locale_paths[0]
self.invoked_for_django = True self.invoked_for_django = True
# Ignoring all contrib apps # Ignoring all contrib apps
self.ignore_patterns += ['contrib/*'] self.ignore_patterns += ['contrib/*']
elif os.path.isdir('locale'):
localedir = os.path.abspath('locale')
else: else:
raise CommandError("This script should be run from the Django Git " self.locale_paths.extend(list(settings.LOCALE_PATHS))
"tree or your project or app tree. If you did indeed run it " # Allow to run makemessages inside an app dir
"from the Git checkout or your project or application, " if os.path.isdir('locale'):
"maybe you are just missing the conf/locale (in the django " self.locale_paths.append(os.path.abspath('locale'))
"tree) or locale (for project and application) directory? It " if self.locale_paths:
"is not created automatically, you have to create it by hand " self.default_locale_path = self.locale_paths[0]
"if you want to enable i18n for your project or application.") if not os.path.exists(self.default_locale_path):

os.makedirs(self.default_locale_path)
check_programs('xgettext')

# Build locale list
potfile = self.build_pot_file(localedir)

# Build po files for each selected locale
locales = [] locales = []
if locale is not None: if locale is not None:
locales = locale locales = locale
elif process_all: elif process_all:
locale_dirs = filter(os.path.isdir, glob.glob('%s/*' % localedir)) locale_dirs = filter(os.path.isdir, glob.glob('%s/*' % self.default_locale_path))
locales = [os.path.basename(l) for l in locale_dirs] locales = [os.path.basename(l) for l in locale_dirs]

if locales: if locales:
check_programs('msguniq', 'msgmerge', 'msgattrib') check_programs('msguniq', 'msgmerge', 'msgattrib')


check_programs('xgettext')

try: try:
potfiles = self.build_potfiles()

# Build po files for each selected locale
for locale in locales: for locale in locales:
if self.verbosity > 0: if self.verbosity > 0:
self.stdout.write("processing locale %s\n" % locale) self.stdout.write("processing locale %s\n" % locale)
self.write_po_file(potfile, locale) for potfile in potfiles:
self.write_po_file(potfile, locale)
finally: finally:
if not self.keep_pot and os.path.exists(potfile): if not self.keep_pot:
os.unlink(potfile) self.remove_potfiles()


def build_pot_file(self, localedir): def build_potfiles(self):
"""
Build pot files and apply msguniq to them.
"""
file_list = self.find_files(".") file_list = self.find_files(".")

self.remove_potfiles()
potfile = os.path.join(localedir, '%s.pot' % str(self.domain))
if os.path.exists(potfile):
# Remove a previous undeleted potfile, if any
os.unlink(potfile)

for f in file_list: for f in file_list:
try: try:
f.process(self, potfile, self.domain, self.keep_pot) f.process(self, self.domain)
except UnicodeDecodeError: except UnicodeDecodeError:
self.stdout.write("UnicodeDecodeError: skipped file %s in %s" % (f.file, f.dirpath)) self.stdout.write("UnicodeDecodeError: skipped file %s in %s" % (f.file, f.dirpath))
return potfile
potfiles = []
for path in self.locale_paths:
potfile = os.path.join(path, '%s.pot' % str(self.domain))
if not os.path.exists(potfile):
continue
args = ['msguniq', '--to-code=utf-8']
if self.wrap:
args.append(self.wrap)
if self.location:
args.append(self.location)
args.append(potfile)
msgs, errors, status = popen_wrapper(args)
if errors:
if status != STATUS_OK:
raise CommandError(
"errors happened while running msguniq\n%s" % errors)
elif self.verbosity > 0:
self.stdout.write(errors)
with open(potfile, 'w') as fp:
fp.write(msgs)
potfiles.append(potfile)
return potfiles

def remove_potfiles(self):
for path in self.locale_paths:
pot_path = os.path.join(path, '%s.pot' % str(self.domain))
if os.path.exists(pot_path):
os.unlink(pot_path)


def find_files(self, root): def find_files(self, root):
""" """
Helper method to get all files in the given root. Helper method to get all files in the given root. Also check that there
is a matching locale dir for each file.
""" """


def is_ignored(path, ignore_patterns): def is_ignored(path, ignore_patterns):
Expand All @@ -319,43 +353,41 @@ def is_ignored(path, ignore_patterns):
dirnames.remove(dirname) dirnames.remove(dirname)
if self.verbosity > 1: if self.verbosity > 1:
self.stdout.write('ignoring directory %s\n' % dirname) self.stdout.write('ignoring directory %s\n' % dirname)
elif dirname == 'locale':
dirnames.remove(dirname)
self.locale_paths.insert(0, os.path.join(os.path.abspath(dirpath), dirname))
for filename in filenames: for filename in filenames:
if is_ignored(os.path.normpath(os.path.join(dirpath, filename)), self.ignore_patterns): file_path = os.path.normpath(os.path.join(dirpath, filename))
if is_ignored(file_path, self.ignore_patterns):
if self.verbosity > 1: if self.verbosity > 1:
self.stdout.write('ignoring file %s in %s\n' % (filename, dirpath)) self.stdout.write('ignoring file %s in %s\n' % (filename, dirpath))
else: else:
all_files.append(TranslatableFile(dirpath, filename)) locale_dir = None
for path in self.locale_paths:
if os.path.abspath(dirpath).startswith(os.path.dirname(path)):
locale_dir = path
break
if not locale_dir:
locale_dir = self.default_locale_path
if not locale_dir:
raise CommandError(
"Unable to find a locale path to store translations for file %s" % file_path)
all_files.append(TranslatableFile(dirpath, filename, locale_dir))
return sorted(all_files) return sorted(all_files)


def write_po_file(self, potfile, locale): def write_po_file(self, potfile, locale):
""" """
Creates or updates the PO file for self.domain and :param locale:. Creates or updates the PO file for self.domain and :param locale:.
Uses contents of the existing :param potfile:. Uses contents of the existing :param potfile:.
Uses mguniq, msgmerge, and msgattrib GNU gettext utilities. Uses msgmerge, and msgattrib GNU gettext utilities.
""" """
args = ['msguniq', '--to-code=utf-8']
if self.wrap:
args.append(self.wrap)
if self.location:
args.append(self.location)
args.append(potfile)
msgs, errors, status = popen_wrapper(args)
if errors:
if status != STATUS_OK:
raise CommandError(
"errors happened while running msguniq\n%s" % errors)
elif self.verbosity > 0:
self.stdout.write(errors)

basedir = os.path.join(os.path.dirname(potfile), locale, 'LC_MESSAGES') basedir = os.path.join(os.path.dirname(potfile), locale, 'LC_MESSAGES')
if not os.path.isdir(basedir): if not os.path.isdir(basedir):
os.makedirs(basedir) os.makedirs(basedir)
pofile = os.path.join(basedir, '%s.po' % str(self.domain)) pofile = os.path.join(basedir, '%s.po' % str(self.domain))


if os.path.exists(pofile): if os.path.exists(pofile):
with open(potfile, 'w') as fp:
fp.write(msgs)
args = ['msgmerge', '-q'] args = ['msgmerge', '-q']
if self.wrap: if self.wrap:
args.append(self.wrap) args.append(self.wrap)
Expand All @@ -369,8 +401,10 @@ def write_po_file(self, potfile, locale):
"errors happened while running msgmerge\n%s" % errors) "errors happened while running msgmerge\n%s" % errors)
elif self.verbosity > 0: elif self.verbosity > 0:
self.stdout.write(errors) self.stdout.write(errors)
elif not self.invoked_for_django: else:
msgs = self.copy_plural_forms(msgs, locale) msgs = open(potfile, 'r').read()
if not self.invoked_for_django:
msgs = self.copy_plural_forms(msgs, locale)
msgs = msgs.replace( msgs = msgs.replace(
"#. #-#-#-#-# %s.pot (PACKAGE VERSION) #-#-#-#-#\n" % self.domain, "") "#. #-#-#-#-# %s.pot (PACKAGE VERSION) #-#-#-#-#\n" % self.domain, "")
with open(pofile, 'w') as fp: with open(pofile, 'w') as fp:
Expand Down
3 changes: 2 additions & 1 deletion docs/man/django-admin.1
Expand Up @@ -192,7 +192,8 @@ Ignore files or directories matching this glob-style pattern. Use multiple
times to ignore more (makemessages command). times to ignore more (makemessages command).
.TP .TP
.I \-\-no\-default\-ignore .I \-\-no\-default\-ignore
Don't ignore the common private glob-style patterns 'CVS', '.*' and '*~' (makemessages command). Don't ignore the common private glob-style patterns 'CVS', '.*', '*~' and '*.pyc'
(makemessages command).
.TP .TP
.I \-\-no\-wrap .I \-\-no\-wrap
Don't break long message lines into several lines (makemessages command). Don't break long message lines into several lines (makemessages command).
Expand Down
4 changes: 2 additions & 2 deletions docs/ref/django-admin.txt
Expand Up @@ -557,7 +557,7 @@ Example usage::
Use the ``--ignore`` or ``-i`` option to ignore files or directories matching Use the ``--ignore`` or ``-i`` option to ignore files or directories matching
the given :mod:`glob`-style pattern. Use multiple times to ignore more. the given :mod:`glob`-style pattern. Use multiple times to ignore more.


These patterns are used by default: ``'CVS'``, ``'.*'``, ``'*~'`` These patterns are used by default: ``'CVS'``, ``'.*'``, ``'*~'``, ``'*.pyc'``


Example usage:: Example usage::


Expand All @@ -584,7 +584,7 @@ for technically skilled translators to understand each message's context.
.. versionadded:: 1.6 .. versionadded:: 1.6


Use the ``--keep-pot`` option to prevent Django from deleting the temporary Use the ``--keep-pot`` option to prevent Django from deleting the temporary
.pot file it generates before creating the .po file. This is useful for .pot files it generates before creating the .po file. This is useful for
debugging errors which may prevent the final language files from being created. debugging errors which may prevent the final language files from being created.


makemigrations [<appname>] makemigrations [<appname>]
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/1.7.txt
Expand Up @@ -375,6 +375,11 @@ Internationalization
in the corresponding entry in the PO file, which makes the translation in the corresponding entry in the PO file, which makes the translation
process easier. process easier.


* When you run :djadmin:`makemessages` from the root directory of your project,
any extracted strings will now be automatically distributed to the proper
app or project message file. See :ref:`how-to-create-language-files` for
details.

Management Commands Management Commands
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^


Expand Down
28 changes: 12 additions & 16 deletions docs/topics/i18n/translation.txt
Expand Up @@ -1256,6 +1256,17 @@ is configured correctly). It creates (or updates) a message file in the
directory ``locale/LANG/LC_MESSAGES``. In the ``de`` example, the file will be directory ``locale/LANG/LC_MESSAGES``. In the ``de`` example, the file will be
``locale/de/LC_MESSAGES/django.po``. ``locale/de/LC_MESSAGES/django.po``.


.. versionchanged:: 1.7

When you run ``makemessages`` from the root directory of your project, the
extracted strings will be automatically distributed to the proper message
files. That is, a string extracted from a file of an app containing a
``locale`` directory will go in a message file under that directory.
A string extracted from a file of an app without any ``locale`` directory
will either go in a message file under the directory listed first in
:setting:`LOCALE_PATHS` or will generate an error if :setting:`LOCALE_PATHS`
is empty.

By default :djadmin:`django-admin.py makemessages <makemessages>` examines every By default :djadmin:`django-admin.py makemessages <makemessages>` examines every
file that has the ``.html`` or ``.txt`` file extension. In case you want to file that has the ``.html`` or ``.txt`` file extension. In case you want to
override that default, use the ``--extension`` or ``-e`` option to specify the override that default, use the ``--extension`` or ``-e`` option to specify the
Expand Down Expand Up @@ -1730,24 +1741,9 @@ All message file repositories are structured the same way. They are:
* ``$PYTHONPATH/django/conf/locale/<language>/LC_MESSAGES/django.(po|mo)`` * ``$PYTHONPATH/django/conf/locale/<language>/LC_MESSAGES/django.(po|mo)``


To create message files, you use the :djadmin:`django-admin.py makemessages <makemessages>` To create message files, you use the :djadmin:`django-admin.py makemessages <makemessages>`
tool. You only need to be in the same directory where the ``locale/`` directory tool. And you use :djadmin:`django-admin.py compilemessages <compilemessages>`
is located. And you use :djadmin:`django-admin.py compilemessages <compilemessages>`
to produce the binary ``.mo`` files that are used by ``gettext``. to produce the binary ``.mo`` files that are used by ``gettext``.


You can also run :djadmin:`django-admin.py compilemessages You can also run :djadmin:`django-admin.py compilemessages
--settings=path.to.settings <compilemessages>` to make the compiler process all --settings=path.to.settings <compilemessages>` to make the compiler process all
the directories in your :setting:`LOCALE_PATHS` setting. the directories in your :setting:`LOCALE_PATHS` setting.

Finally, you should give some thought to the structure of your translation
files. If your applications need to be delivered to other users and will be used
in other projects, you might want to use app-specific translations. But using
app-specific translations and project-specific translations could produce weird
problems with :djadmin:`makemessages`: it will traverse all directories below
the current path and so might put message IDs into a unified, common message
file for the current project that are already in application message files.

The easiest way out is to store applications that are not part of the project
(and so carry their own translations) outside the project tree. That way,
:djadmin:`django-admin.py makemessages <makemessages>`, when ran on a project
level will only extract strings that are connected to your explicit project and
not strings that are distributed independently.
4 changes: 4 additions & 0 deletions tests/i18n/project_dir/__init__.py
@@ -0,0 +1,4 @@
# Sample project used by test_extraction.CustomLayoutExtractionTests
from django.utils.translation import ugettext as _

string = _("This is a project-level string")
Empty file.
3 changes: 3 additions & 0 deletions tests/i18n/project_dir/app_no_locale/models.py
@@ -0,0 +1,3 @@
from django.utils.translation import ugettext as _

string = _("This app has no locale directory")
Empty file.
Empty file.
3 changes: 3 additions & 0 deletions tests/i18n/project_dir/app_with_locale/models.py
@@ -0,0 +1,3 @@
from django.utils.translation import ugettext as _

string = _("This app has a locale directory")
Empty file.

0 comments on commit 50a8ab7

Please sign in to comment.