| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2007 Edgewall Software |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING, which |
|---|
| 7 | # you should have received as part of this distribution. The terms |
|---|
| 8 | # are also available at http://babel.edgewall.org/wiki/License. |
|---|
| 9 | # |
|---|
| 10 | # This software consists of voluntary contributions made by many |
|---|
| 11 | # individuals. For the exact contribution history, see the revision |
|---|
| 12 | # history and logs, available at http://babel.edgewall.org/log/. |
|---|
| 13 | |
|---|
| 14 | """Data structures for message catalogs.""" |
|---|
| 15 | |
|---|
| 16 | from cgi import parse_header |
|---|
| 17 | from datetime import datetime |
|---|
| 18 | from difflib import get_close_matches |
|---|
| 19 | from email import message_from_string |
|---|
| 20 | from copy import copy |
|---|
| 21 | import re |
|---|
| 22 | try: |
|---|
| 23 | set |
|---|
| 24 | except NameError: |
|---|
| 25 | from sets import Set as set |
|---|
| 26 | import time |
|---|
| 27 | |
|---|
| 28 | from babel import __version__ as VERSION |
|---|
| 29 | from babel.core import Locale |
|---|
| 30 | from babel.dates import format_datetime |
|---|
| 31 | from babel.messages.plurals import get_plural |
|---|
| 32 | from babel.util import odict, distinct, LOCALTZ, UTC, FixedOffsetTimezone |
|---|
| 33 | |
|---|
| 34 | __all__ = ['Message', 'Catalog', 'TranslationError'] |
|---|
| 35 | __docformat__ = 'restructuredtext en' |
|---|
| 36 | |
|---|
| 37 | |
|---|
| 38 | PYTHON_FORMAT = re.compile(r'''(?x) |
|---|
| 39 | \% |
|---|
| 40 | (?:\(([\w]*)\))? |
|---|
| 41 | ( |
|---|
| 42 | [-#0\ +]?(?:\*|[\d]+)? |
|---|
| 43 | (?:\.(?:\*|[\d]+))? |
|---|
| 44 | [hlL]? |
|---|
| 45 | ) |
|---|
| 46 | ([diouxXeEfFgGcrs%]) |
|---|
| 47 | ''') |
|---|
| 48 | |
|---|
| 49 | |
|---|
| 50 | class Message(object): |
|---|
| 51 | """Representation of a single message in a catalog.""" |
|---|
| 52 | |
|---|
| 53 | def __init__(self, id, string=u'', locations=(), flags=(), auto_comments=(), |
|---|
| 54 | user_comments=(), previous_id=(), lineno=None): |
|---|
| 55 | """Create the message object. |
|---|
| 56 | |
|---|
| 57 | :param id: the message ID, or a ``(singular, plural)`` tuple for |
|---|
| 58 | pluralizable messages |
|---|
| 59 | :param string: the translated message string, or a |
|---|
| 60 | ``(singular, plural)`` tuple for pluralizable messages |
|---|
| 61 | :param locations: a sequence of ``(filenname, lineno)`` tuples |
|---|
| 62 | :param flags: a set or sequence of flags |
|---|
| 63 | :param auto_comments: a sequence of automatic comments for the message |
|---|
| 64 | :param user_comments: a sequence of user comments for the message |
|---|
| 65 | :param previous_id: the previous message ID, or a ``(singular, plural)`` |
|---|
| 66 | tuple for pluralizable messages |
|---|
| 67 | :param lineno: the line number on which the msgid line was found in the |
|---|
| 68 | PO file, if any |
|---|
| 69 | """ |
|---|
| 70 | self.id = id #: The message ID |
|---|
| 71 | if not string and self.pluralizable: |
|---|
| 72 | string = (u'', u'') |
|---|
| 73 | self.string = string #: The message translation |
|---|
| 74 | self.locations = list(distinct(locations)) |
|---|
| 75 | self.flags = set(flags) |
|---|
| 76 | if id and self.python_format: |
|---|
| 77 | self.flags.add('python-format') |
|---|
| 78 | else: |
|---|
| 79 | self.flags.discard('python-format') |
|---|
| 80 | self.auto_comments = list(distinct(auto_comments)) |
|---|
| 81 | self.user_comments = list(distinct(user_comments)) |
|---|
| 82 | if isinstance(previous_id, basestring): |
|---|
| 83 | self.previous_id = [previous_id] |
|---|
| 84 | else: |
|---|
| 85 | self.previous_id = list(previous_id) |
|---|
| 86 | self.lineno = lineno |
|---|
| 87 | |
|---|
| 88 | def __repr__(self): |
|---|
| 89 | return '<%s %r (flags: %r)>' % (type(self).__name__, self.id, |
|---|
| 90 | list(self.flags)) |
|---|
| 91 | |
|---|
| 92 | def __cmp__(self, obj): |
|---|
| 93 | """Compare Messages, taking into account plural ids""" |
|---|
| 94 | if isinstance(obj, Message): |
|---|
| 95 | plural = self.pluralizable |
|---|
| 96 | obj_plural = obj.pluralizable |
|---|
| 97 | if plural and obj_plural: |
|---|
| 98 | return cmp(self.id[0], obj.id[0]) |
|---|
| 99 | elif plural: |
|---|
| 100 | return cmp(self.id[0], obj.id) |
|---|
| 101 | elif obj_plural: |
|---|
| 102 | return cmp(self.id, obj.id[0]) |
|---|
| 103 | return cmp(self.id, obj.id) |
|---|
| 104 | |
|---|
| 105 | def clone(self): |
|---|
| 106 | return Message(*map(copy, (self.id, self.string, self.locations, |
|---|
| 107 | self.flags, self.auto_comments, |
|---|
| 108 | self.user_comments, self.previous_id, |
|---|
| 109 | self.lineno))) |
|---|
| 110 | |
|---|
| 111 | def check(self, catalog=None): |
|---|
| 112 | """Run various validation checks on the message. Some validations |
|---|
| 113 | are only performed if the catalog is provided. This method returns |
|---|
| 114 | a sequence of `TranslationError` objects. |
|---|
| 115 | |
|---|
| 116 | :rtype: ``iterator`` |
|---|
| 117 | :param catalog: A catalog instance that is passed to the checkers |
|---|
| 118 | :see: `Catalog.check` for a way to perform checks for all messages |
|---|
| 119 | in a catalog. |
|---|
| 120 | """ |
|---|
| 121 | from babel.messages.checkers import checkers |
|---|
| 122 | errors = [] |
|---|
| 123 | for checker in checkers: |
|---|
| 124 | try: |
|---|
| 125 | checker(catalog, self) |
|---|
| 126 | except TranslationError, e: |
|---|
| 127 | errors.append(e) |
|---|
| 128 | return errors |
|---|
| 129 | |
|---|
| 130 | def fuzzy(self): |
|---|
| 131 | return 'fuzzy' in self.flags |
|---|
| 132 | fuzzy = property(fuzzy, doc="""\ |
|---|
| 133 | Whether the translation is fuzzy. |
|---|
| 134 | |
|---|
| 135 | >>> Message('foo').fuzzy |
|---|
| 136 | False |
|---|
| 137 | >>> msg = Message('foo', 'foo', flags=['fuzzy']) |
|---|
| 138 | >>> msg.fuzzy |
|---|
| 139 | True |
|---|
| 140 | >>> msg |
|---|
| 141 | <Message 'foo' (flags: ['fuzzy'])> |
|---|
| 142 | |
|---|
| 143 | :type: `bool` |
|---|
| 144 | """) |
|---|
| 145 | |
|---|
| 146 | def pluralizable(self): |
|---|
| 147 | return isinstance(self.id, (list, tuple)) |
|---|
| 148 | pluralizable = property(pluralizable, doc="""\ |
|---|
| 149 | Whether the message is plurizable. |
|---|
| 150 | |
|---|
| 151 | >>> Message('foo').pluralizable |
|---|
| 152 | False |
|---|
| 153 | >>> Message(('foo', 'bar')).pluralizable |
|---|
| 154 | True |
|---|
| 155 | |
|---|
| 156 | :type: `bool` |
|---|
| 157 | """) |
|---|
| 158 | |
|---|
| 159 | def python_format(self): |
|---|
| 160 | ids = self.id |
|---|
| 161 | if not isinstance(ids, (list, tuple)): |
|---|
| 162 | ids = [ids] |
|---|
| 163 | return bool(filter(None, [PYTHON_FORMAT.search(id) for id in ids])) |
|---|
| 164 | python_format = property(python_format, doc="""\ |
|---|
| 165 | Whether the message contains Python-style parameters. |
|---|
| 166 | |
|---|
| 167 | >>> Message('foo %(name)s bar').python_format |
|---|
| 168 | True |
|---|
| 169 | >>> Message(('foo %(name)s', 'foo %(name)s')).python_format |
|---|
| 170 | True |
|---|
| 171 | |
|---|
| 172 | :type: `bool` |
|---|
| 173 | """) |
|---|
| 174 | |
|---|
| 175 | |
|---|
| 176 | class TranslationError(Exception): |
|---|
| 177 | """Exception thrown by translation checkers when invalid message |
|---|
| 178 | translations are encountered.""" |
|---|
| 179 | |
|---|
| 180 | |
|---|
| 181 | DEFAULT_HEADER = u"""\ |
|---|
| 182 | # Translations template for PROJECT. |
|---|
| 183 | # Copyright (C) YEAR ORGANIZATION |
|---|
| 184 | # This file is distributed under the same license as the PROJECT project. |
|---|
| 185 | # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
|---|
| 186 | #""" |
|---|
| 187 | |
|---|
| 188 | |
|---|
| 189 | class Catalog(object): |
|---|
| 190 | """Representation of a message catalog.""" |
|---|
| 191 | |
|---|
| 192 | def __init__(self, locale=None, domain=None, header_comment=DEFAULT_HEADER, |
|---|
| 193 | project=None, version=None, copyright_holder=None, |
|---|
| 194 | msgid_bugs_address=None, creation_date=None, |
|---|
| 195 | revision_date=None, last_translator=None, language_team=None, |
|---|
| 196 | charset='utf-8', fuzzy=True): |
|---|
| 197 | """Initialize the catalog object. |
|---|
| 198 | |
|---|
| 199 | :param locale: the locale identifier or `Locale` object, or `None` |
|---|
| 200 | if the catalog is not bound to a locale (which basically |
|---|
| 201 | means it's a template) |
|---|
| 202 | :param domain: the message domain |
|---|
| 203 | :param header_comment: the header comment as string, or `None` for the |
|---|
| 204 | default header |
|---|
| 205 | :param project: the project's name |
|---|
| 206 | :param version: the project's version |
|---|
| 207 | :param copyright_holder: the copyright holder of the catalog |
|---|
| 208 | :param msgid_bugs_address: the email address or URL to submit bug |
|---|
| 209 | reports to |
|---|
| 210 | :param creation_date: the date the catalog was created |
|---|
| 211 | :param revision_date: the date the catalog was revised |
|---|
| 212 | :param last_translator: the name and email of the last translator |
|---|
| 213 | :param language_team: the name and email of the language team |
|---|
| 214 | :param charset: the encoding to use in the output |
|---|
| 215 | :param fuzzy: the fuzzy bit on the catalog header |
|---|
| 216 | """ |
|---|
| 217 | self.domain = domain #: The message domain |
|---|
| 218 | if locale: |
|---|
| 219 | locale = Locale.parse(locale) |
|---|
| 220 | self.locale = locale #: The locale or `None` |
|---|
| 221 | self._header_comment = header_comment |
|---|
| 222 | self._messages = odict() |
|---|
| 223 | |
|---|
| 224 | self.project = project or 'PROJECT' #: The project name |
|---|
| 225 | self.version = version or 'VERSION' #: The project version |
|---|
| 226 | self.copyright_holder = copyright_holder or 'ORGANIZATION' |
|---|
| 227 | self.msgid_bugs_address = msgid_bugs_address or 'EMAIL@ADDRESS' |
|---|
| 228 | |
|---|
| 229 | self.last_translator = last_translator or 'FULL NAME <EMAIL@ADDRESS>' |
|---|
| 230 | """Name and email address of the last translator.""" |
|---|
| 231 | self.language_team = language_team or 'LANGUAGE <LL@li.org>' |
|---|
| 232 | """Name and email address of the language team.""" |
|---|
| 233 | |
|---|
| 234 | self.charset = charset or 'utf-8' |
|---|
| 235 | |
|---|
| 236 | if creation_date is None: |
|---|
| 237 | creation_date = datetime.now(LOCALTZ) |
|---|
| 238 | elif isinstance(creation_date, datetime) and not creation_date.tzinfo: |
|---|
| 239 | creation_date = creation_date.replace(tzinfo=LOCALTZ) |
|---|
| 240 | self.creation_date = creation_date #: Creation date of the template |
|---|
| 241 | if revision_date is None: |
|---|
| 242 | revision_date = datetime.now(LOCALTZ) |
|---|
| 243 | elif isinstance(revision_date, datetime) and not revision_date.tzinfo: |
|---|
| 244 | revision_date = revision_date.replace(tzinfo=LOCALTZ) |
|---|
| 245 | self.revision_date = revision_date #: Last revision date of the catalog |
|---|
| 246 | self.fuzzy = fuzzy #: Catalog header fuzzy bit (`True` or `False`) |
|---|
| 247 | |
|---|
| 248 | self.obsolete = odict() #: Dictionary of obsolete messages |
|---|
| 249 | self._num_plurals = None |
|---|
| 250 | self._plural_expr = None |
|---|
| 251 | |
|---|
| 252 | def _get_header_comment(self): |
|---|
| 253 | comment = self._header_comment |
|---|
| 254 | comment = comment.replace('PROJECT', self.project) \ |
|---|
| 255 | .replace('VERSION', self.version) \ |
|---|
| 256 | .replace('YEAR', self.revision_date.strftime('%Y')) \ |
|---|
| 257 | .replace('ORGANIZATION', self.copyright_holder) |
|---|
| 258 | if self.locale: |
|---|
| 259 | comment = comment.replace('Translations template', '%s translations' |
|---|
| 260 | % self.locale.english_name) |
|---|
| 261 | return comment |
|---|
| 262 | |
|---|
| 263 | def _set_header_comment(self, string): |
|---|
| 264 | self._header_comment = string |
|---|
| 265 | |
|---|
| 266 | header_comment = property(_get_header_comment, _set_header_comment, doc="""\ |
|---|
| 267 | The header comment for the catalog. |
|---|
| 268 | |
|---|
| 269 | >>> catalog = Catalog(project='Foobar', version='1.0', |
|---|
| 270 | ... copyright_holder='Foo Company') |
|---|
| 271 | >>> print catalog.header_comment #doctest: +ELLIPSIS |
|---|
| 272 | # Translations template for Foobar. |
|---|
| 273 | # Copyright (C) ... Foo Company |
|---|
| 274 | # This file is distributed under the same license as the Foobar project. |
|---|
| 275 | # FIRST AUTHOR <EMAIL@ADDRESS>, .... |
|---|
| 276 | # |
|---|
| 277 | |
|---|
| 278 | The header can also be set from a string. Any known upper-case variables |
|---|
| 279 | will be replaced when the header is retrieved again: |
|---|
| 280 | |
|---|
| 281 | >>> catalog = Catalog(project='Foobar', version='1.0', |
|---|
| 282 | ... copyright_holder='Foo Company') |
|---|
| 283 | >>> catalog.header_comment = '''\\ |
|---|
| 284 | ... # The POT for my really cool PROJECT project. |
|---|
| 285 | ... # Copyright (C) 1990-2003 ORGANIZATION |
|---|
| 286 | ... # This file is distributed under the same license as the PROJECT |
|---|
| 287 | ... # project. |
|---|
| 288 | ... #''' |
|---|
| 289 | >>> print catalog.header_comment |
|---|
| 290 | # The POT for my really cool Foobar project. |
|---|
| 291 | # Copyright (C) 1990-2003 Foo Company |
|---|
| 292 | # This file is distributed under the same license as the Foobar |
|---|
| 293 | # project. |
|---|
| 294 | # |
|---|
| 295 | |
|---|
| 296 | :type: `unicode` |
|---|
| 297 | """) |
|---|
| 298 | |
|---|
| 299 | def _get_mime_headers(self): |
|---|
| 300 | headers = [] |
|---|
| 301 | headers.append(('Project-Id-Version', |
|---|
| 302 | '%s %s' % (self.project, self.version))) |
|---|
| 303 | headers.append(('Report-Msgid-Bugs-To', self.msgid_bugs_address)) |
|---|
| 304 | headers.append(('POT-Creation-Date', |
|---|
| 305 | format_datetime(self.creation_date, 'yyyy-MM-dd HH:mmZ', |
|---|
| 306 | locale='en'))) |
|---|
| 307 | if self.locale is None: |
|---|
| 308 | headers.append(('PO-Revision-Date', 'YEAR-MO-DA HO:MI+ZONE')) |
|---|
| 309 | headers.append(('Last-Translator', 'FULL NAME <EMAIL@ADDRESS>')) |
|---|
| 310 | headers.append(('Language-Team', 'LANGUAGE <LL@li.org>')) |
|---|
| 311 | else: |
|---|
| 312 | headers.append(('PO-Revision-Date', |
|---|
| 313 | format_datetime(self.revision_date, |
|---|
| 314 | 'yyyy-MM-dd HH:mmZ', locale='en'))) |
|---|
| 315 | headers.append(('Last-Translator', self.last_translator)) |
|---|
| 316 | headers.append(('Language-Team', |
|---|
| 317 | self.language_team.replace('LANGUAGE', |
|---|
| 318 | str(self.locale)))) |
|---|
| 319 | headers.append(('Plural-Forms', self.plural_forms)) |
|---|
| 320 | headers.append(('MIME-Version', '1.0')) |
|---|
| 321 | headers.append(('Content-Type', |
|---|
| 322 | 'text/plain; charset=%s' % self.charset)) |
|---|
| 323 | headers.append(('Content-Transfer-Encoding', '8bit')) |
|---|
| 324 | headers.append(('Generated-By', 'Babel %s\n' % VERSION)) |
|---|
| 325 | return headers |
|---|
| 326 | |
|---|
| 327 | def _set_mime_headers(self, headers): |
|---|
| 328 | for name, value in headers: |
|---|
| 329 | if name.lower() == 'content-type': |
|---|
| 330 | mimetype, params = parse_header(value) |
|---|
| 331 | if 'charset' in params: |
|---|
| 332 | self.charset = params['charset'].lower() |
|---|
| 333 | break |
|---|
| 334 | for name, value in headers: |
|---|
| 335 | name = name.lower().decode(self.charset) |
|---|
| 336 | value = value.decode(self.charset) |
|---|
| 337 | if name == 'project-id-version': |
|---|
| 338 | parts = value.split(' ') |
|---|
| 339 | self.project = u' '.join(parts[:-1]) |
|---|
| 340 | self.version = parts[-1] |
|---|
| 341 | elif name == 'report-msgid-bugs-to': |
|---|
| 342 | self.msgid_bugs_address = value |
|---|
| 343 | elif name == 'last-translator': |
|---|
| 344 | self.last_translator = value |
|---|
| 345 | elif name == 'language-team': |
|---|
| 346 | self.language_team = value |
|---|
| 347 | elif name == 'plural-forms': |
|---|
| 348 | _, params = parse_header(' ;' + value) |
|---|
| 349 | self._num_plurals = int(params.get('nplurals', 2)) |
|---|
| 350 | self._plural_expr = params.get('plural', '(n != 1)') |
|---|
| 351 | elif name == 'pot-creation-date': |
|---|
| 352 | # FIXME: this should use dates.parse_datetime as soon as that |
|---|
| 353 | # is ready |
|---|
| 354 | value, tzoffset, _ = re.split('[+-](\d{4})$', value, 1) |
|---|
| 355 | tt = time.strptime(value, '%Y-%m-%d %H:%M') |
|---|
| 356 | ts = time.mktime(tt) |
|---|
| 357 | tzoffset = FixedOffsetTimezone(int(tzoffset[:2]) * 60 + |
|---|
| 358 | int(tzoffset[2:])) |
|---|
| 359 | dt = datetime.fromtimestamp(ts) |
|---|
| 360 | self.creation_date = dt.replace(tzinfo=tzoffset) |
|---|
| 361 | |
|---|
| 362 | mime_headers = property(_get_mime_headers, _set_mime_headers, doc="""\ |
|---|
| 363 | The MIME headers of the catalog, used for the special ``msgid ""`` entry. |
|---|
| 364 | |
|---|
| 365 | The behavior of this property changes slightly depending on whether a locale |
|---|
| 366 | is set or not, the latter indicating that the catalog is actually a template |
|---|
| 367 | for actual translations. |
|---|
| 368 | |
|---|
| 369 | Here's an example of the output for such a catalog template: |
|---|
| 370 | |
|---|
| 371 | >>> created = datetime(1990, 4, 1, 15, 30, tzinfo=UTC) |
|---|
| 372 | >>> catalog = Catalog(project='Foobar', version='1.0', |
|---|
| 373 | ... creation_date=created) |
|---|
| 374 | >>> for name, value in catalog.mime_headers: |
|---|
| 375 | ... print '%s: %s' % (name, value) |
|---|
| 376 | Project-Id-Version: Foobar 1.0 |
|---|
| 377 | Report-Msgid-Bugs-To: EMAIL@ADDRESS |
|---|
| 378 | POT-Creation-Date: 1990-04-01 15:30+0000 |
|---|
| 379 | PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE |
|---|
| 380 | Last-Translator: FULL NAME <EMAIL@ADDRESS> |
|---|
| 381 | Language-Team: LANGUAGE <LL@li.org> |
|---|
| 382 | MIME-Version: 1.0 |
|---|
| 383 | Content-Type: text/plain; charset=utf-8 |
|---|
| 384 | Content-Transfer-Encoding: 8bit |
|---|
| 385 | Generated-By: Babel ... |
|---|
| 386 | |
|---|
| 387 | And here's an example of the output when the locale is set: |
|---|
| 388 | |
|---|
| 389 | >>> revised = datetime(1990, 8, 3, 12, 0, tzinfo=UTC) |
|---|
| 390 | >>> catalog = Catalog(locale='de_DE', project='Foobar', version='1.0', |
|---|
| 391 | ... creation_date=created, revision_date=revised, |
|---|
| 392 | ... last_translator='John Doe <jd@example.com>', |
|---|
| 393 | ... language_team='de_DE <de@example.com>') |
|---|
| 394 | >>> for name, value in catalog.mime_headers: |
|---|
| 395 | ... print '%s: %s' % (name, value) |
|---|
| 396 | Project-Id-Version: Foobar 1.0 |
|---|
| 397 | Report-Msgid-Bugs-To: EMAIL@ADDRESS |
|---|
| 398 | POT-Creation-Date: 1990-04-01 15:30+0000 |
|---|
| 399 | PO-Revision-Date: 1990-08-03 12:00+0000 |
|---|
| 400 | Last-Translator: John Doe <jd@example.com> |
|---|
| 401 | Language-Team: de_DE <de@example.com> |
|---|
| 402 | Plural-Forms: nplurals=2; plural=(n != 1) |
|---|
| 403 | MIME-Version: 1.0 |
|---|
| 404 | Content-Type: text/plain; charset=utf-8 |
|---|
| 405 | Content-Transfer-Encoding: 8bit |
|---|
| 406 | Generated-By: Babel ... |
|---|
| 407 | |
|---|
| 408 | :type: `list` |
|---|
| 409 | """) |
|---|
| 410 | |
|---|
| 411 | def num_plurals(self): |
|---|
| 412 | if self._num_plurals is None: |
|---|
| 413 | num = 2 |
|---|
| 414 | if self.locale: |
|---|
| 415 | num = get_plural(self.locale)[0] |
|---|
| 416 | self._num_plurals = num |
|---|
| 417 | return self._num_plurals |
|---|
| 418 | num_plurals = property(num_plurals, doc="""\ |
|---|
| 419 | The number of plurals used by the catalog or locale. |
|---|
| 420 | |
|---|
| 421 | >>> Catalog(locale='en').num_plurals |
|---|
| 422 | 2 |
|---|
| 423 | >>> Catalog(locale='ga').num_plurals |
|---|
| 424 | 3 |
|---|
| 425 | |
|---|
| 426 | :type: `int` |
|---|
| 427 | """) |
|---|
| 428 | |
|---|
| 429 | def plural_expr(self): |
|---|
| 430 | if self._plural_expr is None: |
|---|
| 431 | expr = '(n != 1)' |
|---|
| 432 | if self.locale: |
|---|
| 433 | expr = get_plural(self.locale)[1] |
|---|
| 434 | self._plural_expr = expr |
|---|
| 435 | return self._plural_expr |
|---|
| 436 | plural_expr = property(plural_expr, doc="""\ |
|---|
| 437 | The plural expression used by the catalog or locale. |
|---|
| 438 | |
|---|
| 439 | >>> Catalog(locale='en').plural_expr |
|---|
| 440 | '(n != 1)' |
|---|
| 441 | >>> Catalog(locale='ga').plural_expr |
|---|
| 442 | '(n==1 ? 0 : n==2 ? 1 : 2)' |
|---|
| 443 | |
|---|
| 444 | :type: `basestring` |
|---|
| 445 | """) |
|---|
| 446 | |
|---|
| 447 | def plural_forms(self): |
|---|
| 448 | return 'nplurals=%s; plural=%s' % (self.num_plurals, self.plural_expr) |
|---|
| 449 | plural_forms = property(plural_forms, doc="""\ |
|---|
| 450 | Return the plural forms declaration for the locale. |
|---|
| 451 | |
|---|
| 452 | >>> Catalog(locale='en').plural_forms |
|---|
| 453 | 'nplurals=2; plural=(n != 1)' |
|---|
| 454 | >>> Catalog(locale='pt_BR').plural_forms |
|---|
| 455 | 'nplurals=2; plural=(n > 1)' |
|---|
| 456 | |
|---|
| 457 | :type: `str` |
|---|
| 458 | """) |
|---|
| 459 | |
|---|
| 460 | def __contains__(self, id): |
|---|
| 461 | """Return whether the catalog has a message with the specified ID.""" |
|---|
| 462 | return self._key_for(id) in self._messages |
|---|
| 463 | |
|---|
| 464 | def __len__(self): |
|---|
| 465 | """The number of messages in the catalog. |
|---|
| 466 | |
|---|
| 467 | This does not include the special ``msgid ""`` entry. |
|---|
| 468 | """ |
|---|
| 469 | return len(self._messages) |
|---|
| 470 | |
|---|
| 471 | def __iter__(self): |
|---|
| 472 | """Iterates through all the entries in the catalog, in the order they |
|---|
| 473 | were added, yielding a `Message` object for every entry. |
|---|
| 474 | |
|---|
| 475 | :rtype: ``iterator`` |
|---|
| 476 | """ |
|---|
| 477 | buf = [] |
|---|
| 478 | for name, value in self.mime_headers: |
|---|
| 479 | buf.append('%s: %s' % (name, value)) |
|---|
| 480 | flags = set() |
|---|
| 481 | if self.fuzzy: |
|---|
| 482 | flags |= set(['fuzzy']) |
|---|
| 483 | yield Message(u'', '\n'.join(buf), flags=flags) |
|---|
| 484 | for key in self._messages: |
|---|
| 485 | yield self._messages[key] |
|---|
| 486 | |
|---|
| 487 | def __repr__(self): |
|---|
| 488 | locale = '' |
|---|
| 489 | if self.locale: |
|---|
| 490 | locale = ' %s' % self.locale |
|---|
| 491 | return '<%s %r%s>' % (type(self).__name__, self.domain, locale) |
|---|
| 492 | |
|---|
| 493 | def __delitem__(self, id): |
|---|
| 494 | """Delete the message with the specified ID.""" |
|---|
| 495 | key = self._key_for(id) |
|---|
| 496 | if key in self._messages: |
|---|
| 497 | del self._messages[key] |
|---|
| 498 | |
|---|
| 499 | def __getitem__(self, id): |
|---|
| 500 | """Return the message with the specified ID. |
|---|
| 501 | |
|---|
| 502 | :param id: the message ID |
|---|
| 503 | :return: the message with the specified ID, or `None` if no such message |
|---|
| 504 | is in the catalog |
|---|
| 505 | :rtype: `Message` |
|---|
| 506 | """ |
|---|
| 507 | return self._messages.get(self._key_for(id)) |
|---|
| 508 | |
|---|
| 509 | def __setitem__(self, id, message): |
|---|
| 510 | """Add or update the message with the specified ID. |
|---|
| 511 | |
|---|
| 512 | >>> catalog = Catalog() |
|---|
| 513 | >>> catalog[u'foo'] = Message(u'foo') |
|---|
| 514 | >>> catalog[u'foo'] |
|---|
| 515 | <Message u'foo' (flags: [])> |
|---|
| 516 | |
|---|
| 517 | If a message with that ID is already in the catalog, it is updated |
|---|
| 518 | to include the locations and flags of the new message. |
|---|
| 519 | |
|---|
| 520 | >>> catalog = Catalog() |
|---|
| 521 | >>> catalog[u'foo'] = Message(u'foo', locations=[('main.py', 1)]) |
|---|
| 522 | >>> catalog[u'foo'].locations |
|---|
| 523 | [('main.py', 1)] |
|---|
| 524 | >>> catalog[u'foo'] = Message(u'foo', locations=[('utils.py', 5)]) |
|---|
| 525 | >>> catalog[u'foo'].locations |
|---|
| 526 | [('main.py', 1), ('utils.py', 5)] |
|---|
| 527 | |
|---|
| 528 | :param id: the message ID |
|---|
| 529 | :param message: the `Message` object |
|---|
| 530 | """ |
|---|
| 531 | assert isinstance(message, Message), 'expected a Message object' |
|---|
| 532 | key = self._key_for(id) |
|---|
| 533 | current = self._messages.get(key) |
|---|
| 534 | if current: |
|---|
| 535 | if message.pluralizable and not current.pluralizable: |
|---|
| 536 | # The new message adds pluralization |
|---|
| 537 | current.id = message.id |
|---|
| 538 | current.string = message.string |
|---|
| 539 | current.locations = list(distinct(current.locations + |
|---|
| 540 | message.locations)) |
|---|
| 541 | current.auto_comments = list(distinct(current.auto_comments + |
|---|
| 542 | message.auto_comments)) |
|---|
| 543 | current.user_comments = list(distinct(current.user_comments + |
|---|
| 544 | message.user_comments)) |
|---|
| 545 | current.flags |= message.flags |
|---|
| 546 | message = current |
|---|
| 547 | elif id == '': |
|---|
| 548 | # special treatment for the header message |
|---|
| 549 | headers = message_from_string(message.string.encode(self.charset)) |
|---|
| 550 | self.mime_headers = headers.items() |
|---|
| 551 | self.header_comment = '\n'.join(['# %s' % comment for comment |
|---|
| 552 | in message.user_comments]) |
|---|
| 553 | self.fuzzy = message.fuzzy |
|---|
| 554 | else: |
|---|
| 555 | if isinstance(id, (list, tuple)): |
|---|
| 556 | assert isinstance(message.string, (list, tuple)), \ |
|---|
| 557 | 'Expected sequence but got %s' % type(message.string) |
|---|
| 558 | self._messages[key] = message |
|---|
| 559 | |
|---|
| 560 | def add(self, id, string=None, locations=(), flags=(), auto_comments=(), |
|---|
| 561 | user_comments=(), previous_id=(), lineno=None): |
|---|
| 562 | """Add or update the message with the specified ID. |
|---|
| 563 | |
|---|
| 564 | >>> catalog = Catalog() |
|---|
| 565 | >>> catalog.add(u'foo') |
|---|
| 566 | >>> catalog[u'foo'] |
|---|
| 567 | <Message u'foo' (flags: [])> |
|---|
| 568 | |
|---|
| 569 | This method simply constructs a `Message` object with the given |
|---|
| 570 | arguments and invokes `__setitem__` with that object. |
|---|
| 571 | |
|---|
| 572 | :param id: the message ID, or a ``(singular, plural)`` tuple for |
|---|
| 573 | pluralizable messages |
|---|
| 574 | :param string: the translated message string, or a |
|---|
| 575 | ``(singular, plural)`` tuple for pluralizable messages |
|---|
| 576 | :param locations: a sequence of ``(filenname, lineno)`` tuples |
|---|
| 577 | :param flags: a set or sequence of flags |
|---|
| 578 | :param auto_comments: a sequence of automatic comments |
|---|
| 579 | :param user_comments: a sequence of user comments |
|---|
| 580 | :param previous_id: the previous message ID, or a ``(singular, plural)`` |
|---|
| 581 | tuple for pluralizable messages |
|---|
| 582 | :param lineno: the line number on which the msgid line was found in the |
|---|
| 583 | PO file, if any |
|---|
| 584 | """ |
|---|
| 585 | self[id] = Message(id, string, list(locations), flags, auto_comments, |
|---|
| 586 | user_comments, previous_id, lineno=lineno) |
|---|
| 587 | |
|---|
| 588 | def check(self): |
|---|
| 589 | """Run various validation checks on the translations in the catalog. |
|---|
| 590 | |
|---|
| 591 | For every message which fails validation, this method yield a |
|---|
| 592 | ``(message, errors)`` tuple, where ``message`` is the `Message` object |
|---|
| 593 | and ``errors`` is a sequence of `TranslationError` objects. |
|---|
| 594 | |
|---|
| 595 | :rtype: ``iterator`` |
|---|
| 596 | """ |
|---|
| 597 | for message in self._messages.values(): |
|---|
| 598 | errors = message.check(catalog=self) |
|---|
| 599 | if errors: |
|---|
| 600 | yield message, errors |
|---|
| 601 | |
|---|
| 602 | def update(self, template, no_fuzzy_matching=False): |
|---|
| 603 | """Update the catalog based on the given template catalog. |
|---|
| 604 | |
|---|
| 605 | >>> from babel.messages import Catalog |
|---|
| 606 | >>> template = Catalog() |
|---|
| 607 | >>> template.add('green', locations=[('main.py', 99)]) |
|---|
| 608 | >>> template.add('blue', locations=[('main.py', 100)]) |
|---|
| 609 | >>> template.add(('salad', 'salads'), locations=[('util.py', 42)]) |
|---|
| 610 | >>> catalog = Catalog(locale='de_DE') |
|---|
| 611 | >>> catalog.add('blue', u'blau', locations=[('main.py', 98)]) |
|---|
| 612 | >>> catalog.add('head', u'Kopf', locations=[('util.py', 33)]) |
|---|
| 613 | >>> catalog.add(('salad', 'salads'), (u'Salat', u'Salate'), |
|---|
| 614 | ... locations=[('util.py', 38)]) |
|---|
| 615 | |
|---|
| 616 | >>> catalog.update(template) |
|---|
| 617 | >>> len(catalog) |
|---|
| 618 | 3 |
|---|
| 619 | |
|---|
| 620 | >>> msg1 = catalog['green'] |
|---|
| 621 | >>> msg1.string |
|---|
| 622 | >>> msg1.locations |
|---|
| 623 | [('main.py', 99)] |
|---|
| 624 | |
|---|
| 625 | >>> msg2 = catalog['blue'] |
|---|
| 626 | >>> msg2.string |
|---|
| 627 | u'blau' |
|---|
| 628 | >>> msg2.locations |
|---|
| 629 | [('main.py', 100)] |
|---|
| 630 | |
|---|
| 631 | >>> msg3 = catalog['salad'] |
|---|
| 632 | >>> msg3.string |
|---|
| 633 | (u'Salat', u'Salate') |
|---|
| 634 | >>> msg3.locations |
|---|
| 635 | [('util.py', 42)] |
|---|
| 636 | |
|---|
| 637 | Messages that are in the catalog but not in the template are removed |
|---|
| 638 | from the main collection, but can still be accessed via the `obsolete` |
|---|
| 639 | member: |
|---|
| 640 | |
|---|
| 641 | >>> 'head' in catalog |
|---|
| 642 | False |
|---|
| 643 | >>> catalog.obsolete.values() |
|---|
| 644 | [<Message 'head' (flags: [])>] |
|---|
| 645 | |
|---|
| 646 | :param template: the reference catalog, usually read from a POT file |
|---|
| 647 | :param no_fuzzy_matching: whether to use fuzzy matching of message IDs |
|---|
| 648 | """ |
|---|
| 649 | messages = self._messages |
|---|
| 650 | remaining = messages.copy() |
|---|
| 651 | self._messages = odict() |
|---|
| 652 | |
|---|
| 653 | # Prepare for fuzzy matching |
|---|
| 654 | fuzzy_candidates = [] |
|---|
| 655 | if not no_fuzzy_matching: |
|---|
| 656 | fuzzy_candidates = [ |
|---|
| 657 | self._key_for(msgid) for msgid in messages |
|---|
| 658 | if msgid and messages[msgid].string |
|---|
| 659 | ] |
|---|
| 660 | fuzzy_matches = set() |
|---|
| 661 | |
|---|
| 662 | def _merge(message, oldkey, newkey): |
|---|
| 663 | message = message.clone() |
|---|
| 664 | fuzzy = False |
|---|
| 665 | if oldkey != newkey: |
|---|
| 666 | fuzzy = True |
|---|
| 667 | fuzzy_matches.add(oldkey) |
|---|
| 668 | oldmsg = messages.get(oldkey) |
|---|
| 669 | if isinstance(oldmsg.id, basestring): |
|---|
| 670 | message.previous_id = [oldmsg.id] |
|---|
| 671 | else: |
|---|
| 672 | message.previous_id = list(oldmsg.id) |
|---|
| 673 | else: |
|---|
| 674 | oldmsg = remaining.pop(oldkey, None) |
|---|
| 675 | message.string = oldmsg.string |
|---|
| 676 | if isinstance(message.id, (list, tuple)): |
|---|
| 677 | if not isinstance(message.string, (list, tuple)): |
|---|
| 678 | fuzzy = True |
|---|
| 679 | message.string = tuple( |
|---|
| 680 | [message.string] + ([u''] * (len(message.id) - 1)) |
|---|
| 681 | ) |
|---|
| 682 | elif len(message.string) != len(message.id): |
|---|
| 683 | fuzzy = True |
|---|
| 684 | message.string = tuple(message.string[:len(oldmsg.string)]) |
|---|
| 685 | elif isinstance(message.string, (list, tuple)): |
|---|
| 686 | fuzzy = True |
|---|
| 687 | message.string = message.string[0] |
|---|
| 688 | message.flags |= oldmsg.flags |
|---|
| 689 | if fuzzy: |
|---|
| 690 | message.flags |= set([u'fuzzy']) |
|---|
| 691 | self[message.id] = message |
|---|
| 692 | |
|---|
| 693 | for message in template: |
|---|
| 694 | if message.id: |
|---|
| 695 | key = self._key_for(message.id) |
|---|
| 696 | if key in messages: |
|---|
| 697 | _merge(message, key, key) |
|---|
| 698 | else: |
|---|
| 699 | if no_fuzzy_matching is False: |
|---|
| 700 | # do some fuzzy matching with difflib |
|---|
| 701 | matches = get_close_matches(key.lower().strip(), |
|---|
| 702 | fuzzy_candidates, 1) |
|---|
| 703 | if matches: |
|---|
| 704 | _merge(message, matches[0], key) |
|---|
| 705 | continue |
|---|
| 706 | |
|---|
| 707 | self[message.id] = message |
|---|
| 708 | |
|---|
| 709 | self.obsolete = odict() |
|---|
| 710 | for msgid in remaining: |
|---|
| 711 | if no_fuzzy_matching or msgid not in fuzzy_matches: |
|---|
| 712 | self.obsolete[msgid] = remaining[msgid] |
|---|
| 713 | |
|---|
| 714 | def _key_for(self, id): |
|---|
| 715 | """The key for a message is just the singular ID even for pluralizable |
|---|
| 716 | messages. |
|---|
| 717 | """ |
|---|
| 718 | key = id |
|---|
| 719 | if isinstance(key, (list, tuple)): |
|---|
| 720 | key = id[0] |
|---|
| 721 | return key |
|---|