1###
2# Copyright (c) 2010, Valentin Lorentz
3# All rights reserved.
4#
5# Redistribution and use in source and binary forms, with or without
6# modification, are permitted provided that the following conditions are met:
7#
8#   * Redistributions of source code must retain the above copyright notice,
9#     this list of conditions, and the following disclaimer.
10#   * Redistributions in binary form must reproduce the above copyright notice,
11#     this list of conditions, and the following disclaimer in the
12#     documentation and/or other materials provided with the distribution.
13#   * Neither the name of the author of this software nor the name of
14#     contributors to this software may be used to endorse or promote products
15#     derived from this software without specific prior written consent.
16#
17# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27# POSSIBILITY OF SUCH DAMAGE.
28###
29
30"""
31Supybot internationalisation and localisation managment.
32"""
33
34__all__ = ['PluginInternationalization', 'internationalizeDocstring']
35
36import os
37import sys
38import weakref
39conf = None
40# Don't import conf here ; because conf needs this module
41
42WAITING_FOR_MSGID = 1
43IN_MSGID = 2
44WAITING_FOR_MSGSTR = 3
45IN_MSGSTR = 4
46
47MSGID = 'msgid "'
48MSGSTR = 'msgstr "'
49
50currentLocale = 'en'
51
52class PluginNotFound(Exception):
53    pass
54
55def getLocaleFromRegistryFilename(filename):
56    """Called by the 'supybot' script. Gets the locale name before conf is
57    loaded."""
58    global currentLocale
59    with open(filename, 'r') as fd:
60        for line in fd:
61            if line.startswith('supybot.language: '):
62                currentLocale = line[len('supybot.language: '):]
63
64def import_conf():
65    """Imports the conf into this module"""
66    global conf
67    conf = __import__('supybot.conf').conf
68    conf.registerGlobalValue(conf.supybot, 'language',
69        conf.registry.String(currentLocale, """Determines the bot's default
70        language if translations exist. Currently supported are 'de', 'en',
71        'es', 'fi', 'fr' and 'it'."""))
72    conf.supybot.language.addCallback(reloadLocalesIfRequired)
73
74def getPluginDir(plugin_name):
75    """Gets the directory of the given plugin"""
76    filename = None
77    try:
78        filename = sys.modules[plugin_name].__file__
79    except KeyError: # It sometimes happens with Owner
80        pass
81    if filename == None:
82        try:
83            filename = sys.modules['supybot.plugins.' + plugin_name].__file__
84        except: # In the case where the plugin is not loaded by Supybot
85            try:
86                filename = sys.modules['plugin'].__file__
87            except:
88                filename = sys.modules['__main__'].__file__
89    if filename.endswith(".pyc"):
90        filename = filename[0:-1]
91
92    allowed_files = ['__init__.py', 'config.py', 'plugin.py', 'test.py']
93    for allowed_file in allowed_files:
94        if filename.endswith(allowed_file):
95            return filename[0:-len(allowed_file)]
96    raise PluginNotFound()
97
98def getLocalePath(name, localeName, extension):
99    """Gets the path of the locale file of the given plugin ('supybot' stands
100    for the core)."""
101    if name != 'supybot':
102        base = getPluginDir(name)
103    else:
104        from . import ansi # Any Supybot plugin could fit
105        base = ansi.__file__[0:-len('ansi.pyc')]
106    directory = os.path.join(base, 'locales')
107    return '%s/%s.%s' % (directory, localeName, extension)
108
109i18nClasses = weakref.WeakValueDictionary()
110internationalizedCommands = weakref.WeakValueDictionary()
111internationalizedFunctions = [] # No need to know their name
112
113def reloadLocalesIfRequired():
114    global currentLocale
115    if conf is None:
116        return
117    if currentLocale != conf.supybot.language():
118        currentLocale = conf.supybot.language()
119        reloadLocales()
120
121def reloadLocales():
122    for pluginClass in i18nClasses.values():
123        pluginClass.loadLocale()
124    for command in list(internationalizedCommands.values()):
125        internationalizeDocstring(command)
126    for function in internationalizedFunctions:
127        function.loadLocale()
128
129def normalize(string, removeNewline=False):
130    import supybot.utils as utils
131    string = string.replace('\\n\\n', '\n\n')
132    string = string.replace('\\n', ' ')
133    string = string.replace('\\"', '"')
134    string = string.replace("\'", "'")
135    string = utils.str.normalizeWhitespace(string, removeNewline)
136    string = string.strip('\n')
137    string = string.strip('\t')
138    return string
139
140
141def parse(translationFile):
142    step = WAITING_FOR_MSGID
143    translations = set()
144    for line in translationFile:
145        line = line[0:-1] # Remove the ending \n
146        line = line
147
148        if line.startswith(MSGID):
149            # Don't check if step is WAITING_FOR_MSGID
150            untranslated = ''
151            translated = ''
152            data = line[len(MSGID):-1]
153            if len(data) == 0: # Multiline mode
154                step = IN_MSGID
155            else:
156                untranslated += data
157                step = WAITING_FOR_MSGSTR
158
159
160        elif step is IN_MSGID and line.startswith('"') and \
161                                  line.endswith('"'):
162            untranslated += line[1:-1]
163        elif step is IN_MSGID and untranslated == '': # Empty MSGID
164            step = WAITING_FOR_MSGID
165        elif step is IN_MSGID: # the MSGID is finished
166            step = WAITING_FOR_MSGSTR
167
168
169        if step is WAITING_FOR_MSGSTR and line.startswith(MSGSTR):
170            data = line[len(MSGSTR):-1]
171            if len(data) == 0: # Multiline mode
172                step = IN_MSGSTR
173            else:
174                translations |= set([(untranslated, data)])
175                step = WAITING_FOR_MSGID
176
177
178        elif step is IN_MSGSTR and line.startswith('"') and \
179                                   line.endswith('"'):
180            translated += line[1:-1]
181        elif step is IN_MSGSTR: # the MSGSTR is finished
182            step = WAITING_FOR_MSGID
183            if translated == '':
184                translated = untranslated
185            translations |= set([(untranslated, translated)])
186    if step is IN_MSGSTR:
187        if translated == '':
188            translated = untranslated
189        translations |= set([(untranslated, translated)])
190    return translations
191
192
193i18nSupybot = None
194def PluginInternationalization(name='supybot'):
195    # This is a proxy that prevents having several objects for the same plugin
196    if name in i18nClasses:
197        return i18nClasses[name]
198    else:
199        return _PluginInternationalization(name)
200
201class _PluginInternationalization:
202    """Internationalization managment for a plugin."""
203    def __init__(self, name='supybot'):
204        self.name = name
205        self.translations = {}
206        self.currentLocaleName = None
207        i18nClasses.update({name: self})
208        self.loadLocale()
209
210    def loadLocale(self, localeName=None):
211        """(Re)loads the locale used by this class."""
212        self.translations = {}
213        if localeName is None:
214            localeName = currentLocale
215        self.currentLocaleName = localeName
216
217        self._loadL10nCode()
218
219        try:
220            try:
221                translationFile = open(getLocalePath(self.name,
222                                                     localeName, 'po'), 'ru')
223            except ValueError: # We are using Windows
224                translationFile = open(getLocalePath(self.name,
225                                                     localeName, 'po'), 'r')
226            self._parse(translationFile)
227        except (IOError, PluginNotFound): # The translation is unavailable
228            pass
229        finally:
230            if 'translationFile' in locals():
231                translationFile.close()
232
233    def _parse(self, translationFile):
234        """A .po files parser.
235
236        Give it a file object."""
237        self.translations = {}
238        for translation in parse(translationFile):
239            self._addToDatabase(*translation)
240
241    def _addToDatabase(self, untranslated, translated):
242        untranslated = normalize(untranslated, True)
243        translated = normalize(translated)
244        if translated:
245            self.translations.update({untranslated: translated})
246
247    def __call__(self, untranslated):
248        """Main function.
249
250        This is the function which is called when a plugin runs _()"""
251        normalizedUntranslated = normalize(untranslated, True)
252        try:
253            string = self._translate(normalizedUntranslated)
254            return self._addTracker(string, untranslated)
255        except KeyError:
256            pass
257        if untranslated.__class__ is InternationalizedString:
258            return untranslated._original
259        else:
260            return untranslated
261
262    def _translate(self, string):
263        """Translate the string.
264
265        C the string internationalizer if any; else, use the local database"""
266        if string.__class__ == InternationalizedString:
267            return string._internationalizer(string.untranslated)
268        else:
269            return self.translations[string]
270
271    def _addTracker(self, string, untranslated):
272        """Add a kind of 'tracker' on the string, in order to keep the
273        untranslated string (used when changing the locale)"""
274        if string.__class__ == InternationalizedString:
275            return string
276        else:
277            string = InternationalizedString(string)
278            string._original = untranslated
279            string._internationalizer = self
280            return string
281
282    def _loadL10nCode(self):
283        """Open the file containing the code specific to this locale, and
284        load its functions."""
285        if self.name != 'supybot':
286            return
287        path = self._getL10nCodePath()
288        try:
289            with open(path) as fd:
290                exec(compile(fd.read(), path, 'exec'))
291        except IOError: # File doesn't exist
292            pass
293
294        functions = locals()
295        functions.pop('self')
296        self._l10nFunctions = functions
297            # Remove old functions and come back to the native language
298
299    def _getL10nCodePath(self):
300        """Returns the path to the code localization file.
301
302        It contains functions that needs to by fully (code + strings)
303        localized"""
304        if self.name != 'supybot':
305            return
306        return getLocalePath('supybot', self.currentLocaleName, 'py')
307
308    def localizeFunction(self, name):
309        """Returns the localized version of the function.
310
311        Should be used only by the InternationalizedFunction class"""
312        if self.name != 'supybot':
313            return
314        if hasattr(self, '_l10nFunctions') and \
315                name in self._l10nFunctions:
316            return self._l10nFunctions[name]
317
318    def internationalizeFunction(self, name):
319        """Decorates functions and internationalize their code.
320
321        Only useful for Supybot core functions"""
322        if self.name != 'supybot':
323            return
324        class FunctionInternationalizer:
325            def __init__(self, parent, name):
326                self._parent = parent
327                self._name = name
328            def __call__(self, obj):
329                obj = InternationalizedFunction(self._parent, self._name, obj)
330                obj.loadLocale()
331                return obj
332        return FunctionInternationalizer(self, name)
333
334class InternationalizedFunction:
335    """Proxy for functions that need to be fully localized.
336
337    The localization code is in locales/LOCALE.py"""
338    def __init__(self, internationalizer, name, function):
339        self._internationalizer = internationalizer
340        self._name = name
341        self._origin = function
342        internationalizedFunctions.append(self)
343    def loadLocale(self):
344        self.__call__ = self._internationalizer.localizeFunction(self._name)
345        if self.__call__ == None:
346            self.restore()
347    def restore(self):
348        self.__call__ = self._origin
349
350    def __call__(self, *args, **kwargs):
351        return self._origin(*args, **kwargs)
352
353try:
354    class InternationalizedString(str):
355        """Simple subclass to str, that allow to add attributes. Also used to
356        know if a string is already localized"""
357        __slots__ = ('_original', '_internationalizer')
358except TypeError:
359    # Fallback for CPython 2.x:
360    # TypeError: Error when calling the metaclass bases
361    #     nonempty __slots__ not supported for subtype of 'str'
362    class InternationalizedString(str):
363        """Simple subclass to str, that allow to add attributes. Also used to
364        know if a string is already localized"""
365        pass
366
367def internationalizeDocstring(obj):
368    """Decorates functions and internationalize their docstring.
369
370    Only useful for commands (commands' docstring is displayed on IRC)"""
371    if obj.__doc__ == None:
372        return obj
373    plugin_module = sys.modules[obj.__module__]
374    if '_' in plugin_module.__dict__:
375        internationalizedCommands.update({hash(obj): obj})
376        try:
377            obj.__doc__ = plugin_module._.__call__(obj.__doc__)
378            # We use _.__call__() instead of _() because of a pygettext warning.
379        except AttributeError:
380            # attribute '__doc__' of 'type' objects is not writable
381            pass
382    return obj
383