diff options
| author | Chris McDonough <chrism@agendaless.com> | 2010-04-25 00:02:12 +0000 |
|---|---|---|
| committer | Chris McDonough <chrism@agendaless.com> | 2010-04-25 00:02:12 +0000 |
| commit | 7534bae0c5eeb34a0146e76a81a9312797f6ba5c (patch) | |
| tree | 15d3fd388eb3c5538c50da20c3691a9f39ca6de3 | |
| parent | 51981a128c7ed05e51938a3f358c0970dcc33a6f (diff) | |
| download | pyramid-7534bae0c5eeb34a0146e76a81a9312797f6ba5c.tar.gz pyramid-7534bae0c5eeb34a0146e76a81a9312797f6ba5c.tar.bz2 pyramid-7534bae0c5eeb34a0146e76a81a9312797f6ba5c.zip | |
Merge i18n branch via svn merge --ignore-ancestry -r9030:9150 $REPOZE_SVN/repoze.bfg/branches/i18n
No foreigners were harmed in the coding of this feature.
48 files changed, 2328 insertions, 13 deletions
diff --git a/CHANGES.txt b/CHANGES.txt index fdc67b191..1d38ff462 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -22,7 +22,7 @@ Features returned. Other normal view predicates can also be used in combination with an - exception view registration: + exception view registration:: from repoze.bfg.view import bfg_view from repoze.bfg.exceptions import NotFound @@ -58,6 +58,22 @@ Features scanner. (Truth be told, Venusian is really just a generalization of the BFG-internal decorator scanner). +- Internationalization and localization features as documented in the + narrative documentation chapter entitled ``Internationalization and + Localization``. + +- A new setting named ``default_locale_name`` was added. If this + string is present as a Paster ``.ini`` file option, it will be + considered the default locale name. The default locale name is used + during locale-related operations such as language translation. + +ZCML +---- + +- Add a ``translationdir`` ZCML directive to support localization. + +- Add a ``localenegotiator`` ZCML directive to support localization. + Deprecations ------------ @@ -74,6 +90,12 @@ Dependencies - A new install-time dependency on the ``venusian`` distribution was added. +- A new install-time dependency on the ``translationstring`` + distribution was added. + +- Chameleon 1.2.3 or better is now required (internationalization and + per-template debug settings). + Internal -------- @@ -96,3 +118,32 @@ Documentation - Exception view documentation was added to the ``Hooks`` narrative chapter. + +- A new narrative chapter entitled ``Internationalization and + Localization`` was added. + +- The "Environment Variables and ``ini`` File Settings" chapter was + changed: documentation about the ``default_locale_name`` setting was + added. + +- A new API chapter for the ``repoze.bfg.i18n`` module was added. + +- Documentation for the new ``translationdir`` and + ``locale_negotiator`` ZCML directives were added. + +Paster Templates +---------------- + +- All paster templates now create a ``setup.cfg`` which includes + commands related to nose testing and Babel message catalog + extraction/compilation. + +- A ``default_locale_name = en`` setting was added to each existing paster + template. + +Licensing +--------- + +- The Edgewall (BSD) license was added to the LICENSES.txt file, as + some code in the ``repoze.bfg.i18n`` derives from Babel source. + diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt index 7b03cc535..aa870dc84 100644 --- a/COPYRIGHT.txt +++ b/COPYRIGHT.txt @@ -4,3 +4,5 @@ Copyright (c) 2008-2010 Agendaless Consulting and Contributors. Portions (c) Django Project (http://djangoproject.com/). Portions (c) Zope Foundation and contributors (http://www.zope.org/). + +Portions (c) Edgewall Software (http://edgewall.org) diff --git a/LICENSE.txt b/LICENSE.txt index 0f9145223..c2c18cdb5 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -156,3 +156,35 @@ The documentation portion of repoze.bfg (the rendered contents of the under the Creative Commons Attribution-Noncommercial-Share Alike 3.0 United States License as described by http://creativecommons.org/licenses/by-nc-sa/3.0/us/ + +Internationalization Code in ``repoze.bfg.i18n`` is supplied under the +following license: + + Copyright (C) 2007 Edgewall Software + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS + OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN + IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. @@ -17,3 +17,5 @@ - Provide a webob.Response class facade for forward compat. +- Replace default_notfound_view and default_forbidden_view with better + exception view candidates. diff --git a/docs/api.rst b/docs/api.rst index 2bd5fca01..a97c79fa9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,6 +15,7 @@ documentation is organized alphabetically by module name. api/configuration api/events api/exceptions + api/i18n api/interfaces api/location api/paster diff --git a/docs/api/configuration.rst b/docs/api/configuration.rst index e872a91b9..7b411b92d 100644 --- a/docs/api/configuration.rst +++ b/docs/api/configuration.rst @@ -32,6 +32,8 @@ .. automethod:: add_subscriber + .. automethod:: add_translation_dirs + .. automethod:: add_view .. automethod:: load_zcml(spec) @@ -46,6 +48,8 @@ .. automethod:: set_notfound_view(view=None, attr=None, renderer=None, wrapper=None) + .. automethod:: set_locale_negotiator + .. automethod:: testing_securitypolicy .. automethod:: testing_models diff --git a/docs/api/i18n.rst b/docs/api/i18n.rst new file mode 100644 index 000000000..6562bb13b --- /dev/null +++ b/docs/api/i18n.rst @@ -0,0 +1,31 @@ +.. _i18n_module: + +:mod:`repoze.bfg.i18n` +---------------------- + +.. automodule:: repoze.bfg.i18n + + .. autoclass:: TranslationString + + .. autoclass:: TranslationStringFactory + + .. autoclass:: Localizer + :members: + + .. attribute:: locale_name + + The locale name for this localizer (e.g. ``en`` or ``en_US``). + + .. autofunction:: get_localizer + + .. autofunction:: negotiate_locale_name + + .. autofunction:: get_locale_name + + .. autofunction:: default_locale_negotiator + +See :ref:`i18n_chapter` for more information about using +:mod:`repoze.bfg` internationalization and localization services +within an application. + + diff --git a/docs/glossary.rst b/docs/glossary.rst index 14bd4fc44..026db614a 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -667,3 +667,76 @@ Glossary at import time, the action usually taken by the decorator is deferred until a separate "scan" phase. :mod:`repoze.bfg` relies on Venusian to provide a basis for its :term:`scan` feature. + + Translation String + An instance of :class:`repoze.bfg.i18n.TranslationString`, which + is a class that behaves like a Unicode string, but has several + extra attributes such as ``domain``, ``msgid``, and ``mapping`` + for use during translation. Translation strings are usually + created by hand within software, but are sometimes created on the + behalf of the system for automatic template translation. For + more information, see :ref:`i18n_chapter`. + + Translation Domain + A string representing the "context" in which a translation was + made. For example the word "java" might be translated + differently if the translation domain is "programming-languages" + than would be if the translation domain was "coffee". A + translation domain is represnted by a collection of ``.mo`` files + within one or more :term:`translation directory` directories. + + Translator + A callable which receives a :term:`translation string` and + returns a translated Unicode object for the purposes of + internationalization. A :term:`localizer` supplies a + translator to a :mod:`repoze.bfg` application accessible via its + ``translate`` method. + + Translation Directory + A translation directory is a :term:`gettext` translation + directory. It contains language folders, which themselves + contain ``LC_MESSAGES`` folders, which contain ``.mo`` files. + Each ``.mo`` file represents a set of translations for a language + in a :term:`translation domain`. The name of the ``.mo`` file + (minus the .mo extension) is the translation domain name. + + Localizer + An instance of the class :class:`repoze.bfg.i18n.Localizer` which + provides translation and pluralization services to an + application. It is retrieved via the + :func:`repoze.bfg.i18n.get_localizer` function. + + Locale Name + A string like ``en``, ``en_US``, ``de``, or ``de_AT`` which + uniquely identifies a particular locale. + + Locale Negotiator + An object supplying a policy determining which :term:`locale + name` best represents a given :term:`request`. It is used by the + :func:`repoze.bfg.i18n.get_locale_name`, and + :func:`repoze.bfg.i18n.negotiate_locale_name` functions, and + indirectly by :func:`repoze.bfg.i18n.get_localizer`. The + :func:`repoze.bfg.i18n.default_locale_negotiator` function + is an example of a locale negotiator. + + Gettext + The GNU `gettext <http://www.gnu.org/software/gettext/>`_ + library, used by the :mod:`repoze.bfg` translation machinery. + + Babel + A `collection of tools <http://babel.edgewall.org/>`_ for + internationalizing Python applications. :mod:`repoze.bfg` does + not depend on Babel to operate, but if Babel is installed, + additional locale functionality becomes available to your + application. + + Message Identifier + A string used as a translation lookup key during localization. + The ``msgid`` argument to a :term:`translation string` is a + message identifier. Message identifiers are also present in a + :term:`message catalog`. + + Message Catalog + A :term:`gettext` ``.mo`` file containing translations. + + diff --git a/docs/index.rst b/docs/index.rst index ee32cf691..c1b185352 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,6 +53,7 @@ Narrative documentation in chapter form explaining how to use narr/templates narr/models narr/security + narr/i18n narr/vhosting narr/events narr/environment diff --git a/docs/latexindex.rst b/docs/latexindex.rst index 45438e939..5850ee06c 100644 --- a/docs/latexindex.rst +++ b/docs/latexindex.rst @@ -42,6 +42,7 @@ Narrative Documentation narr/templates narr/models narr/security + narr/i18n narr/vhosting narr/events narr/environment diff --git a/docs/narr/MyProject/MyProject.ini b/docs/narr/MyProject/MyProject.ini index f5d094dff..38cbf879c 100644 --- a/docs/narr/MyProject/MyProject.ini +++ b/docs/narr/MyProject/MyProject.ini @@ -6,6 +6,7 @@ use = egg:MyProject#app reload_templates = true debug_authorization = false debug_notfound = false +default_locale_name = en [server:main] use = egg:Paste#http diff --git a/docs/narr/MyProject/setup.cfg b/docs/narr/MyProject/setup.cfg new file mode 100644 index 000000000..de38b5616 --- /dev/null +++ b/docs/narr/MyProject/setup.cfg @@ -0,0 +1,27 @@ +[nosetests] +match=^test +nocapture=1 +cover-package=myproject +with-coverage=1 +cover-erase=1 + +[compile_catalog] +directory = myproject/locale +domain = MyProject +statistics = true + +[extract_messages] +add_comments = TRANSLATORS: +output_file = myproject/locale/MyProject.pot +width = 80 + +[init_catalog] +domain = MyProject +input_file = myproject/locale/MyProject.pot +output_dir = myproject/locale + +[update_catalog] +domain = MyProject +input_file = myproject/locale/MyProject.pot +output_dir = myproject/locale +previous = true diff --git a/docs/narr/environment.rst b/docs/narr/environment.rst index 5af24bfac..a9bcbf70a 100644 --- a/docs/narr/environment.rst +++ b/docs/narr/environment.rst @@ -9,6 +9,7 @@ single: reload_all single: debug settings single: reload settings + single: default_locale_name single: environment variables single: ini file settings single: PasteDeploy settings @@ -121,6 +122,22 @@ Turns on all ``reload*`` settings. | | | +---------------------------------+-----------------------------+ +Default Locale Name +-------------------- + +The value supplied here is used as the default locale name when a +:term:`locale negotiator` is not registered. See also +:ref:`localization_deployment_settings`. + ++---------------------------------+-----------------------------+ +| Environment Variable Name | Config File Setting Name | ++=================================+=============================+ +| ``BFG_DEFAULT_LOCALE_NAME`` | ``default_locale_name`` | +| | | +| | | +| | | ++---------------------------------+-----------------------------+ + Examples -------- diff --git a/docs/narr/i18n.rst b/docs/narr/i18n.rst new file mode 100644 index 000000000..7ec6b2607 --- /dev/null +++ b/docs/narr/i18n.rst @@ -0,0 +1,777 @@ +.. index:: + single: i18n + single: l10n + single: internationalization + single: localization + +.. _i18n_chapter: + +Internationalization and Localization +===================================== + +:mod:`repoze.bfg` offers internationalization (i18n) and localization +(l10n) subsystems that can be used to translate the text of buttons, +the text of error messages and other software- and template-defined +values into the native language of a user of your application. + +.. note: The APIs and functionality described in this chapter are new + as of :mod:`repoze.bfg` version 1.3. + +.. index:: + single: translation string + pair: domain; translation + pair: msgid; translation + single: message identifier + +Creating a Translation String +----------------------------- + +While you write your software, you can insert specialized markup into +your Python code that makes it possible for the system to translate +text values into the languages used by your application's users. This +markup generates a :term:`translation string`. A translation string +is an object that behave mostly like a normal Unicode object, except +that it also carries around extra information related to its job as +part of the :mod:`repoze.bfg` translation machinery. + +Using The ``TranslationString`` Class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The most primitive way to create a translation string is to use the +:class:`repoze.bfg.i18n.TranslationString` callable: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationString + ts = TranslationString('Add') + +This creates a Unicode-like object that is a TranslationString. + +.. note:: + + For people more familiar with :term:`Zope` i8n, a TranslationString + is a lot like a ``zope.i18nmessageid.Message`` object. It is not a + subclass, however. For people more familiar with :term:`Pylons` or + :term:`Django` i8n, using a TranslationString is a lot like using + "lazy" versions of related gettext APIs. + +The first argument to :class:`repoze.bfg.i18n.TranslationString` is +the ``msgid``; it is required. It represents the key into the +translation mappings provided by a particular localization. The +``msgid`` argument must be a Unicode object or an ASCII string. The +msgid may optionally contain *replacement markers*. For instance: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationString + ts = TranslationString('Add ${number}') + +Within the string above, ``${stuff}`` is a replacement marker. It +will be replaced by whatever is in the *mapping* for a translation +string. The mapping may be supplied at the same time as the +replacement marker itself: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationString + ts = TranslationString('Add ${number}', mapping={'number':1}) + +Any number of replacement markers can be present in the msgid value, +any number of times. Only markers which can be replaced by the values +in the *mapping* will be replaced at translation time. The others +will not be interpolated and will be output literally. + +A translation string should also usually carry a *domain*. The domain +represents a translation category to disambiguate it from other +translations of the same msgid, in case they conflict. + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationString + ts = TranslationString('Add ${number}', mapping={'number':1}, + domain='form') + +The above translation string named a domain of ``form``. A +:term:`translator` function will often use the domain to locate the +right translator file on the filesystem which contains translations +for a given domain. In this case, if it were trying to translate to +our msgid to German, it might try to find a translation from a +:term:`gettext` file within a :term:`translation directory` like this +one:: + + locale/de/LC_MESSAGES/form.mo + +In other words, it would want to take translations from the ``form.mo`` +translation file in the German language. + +Finally, the TranslationString constructor accepts a ``default`` +argument. If a ``default`` argument is supplied, it replaces usages +of the ``msgid`` as the *default value* for the translation string. +When ``default`` is ``None``, the ``msgid`` value passed to a +TranslationString is used as an implicit message identifier. Message +identifiers are matched with translations in translation files, so it +is often useful to create translation strings with "opaque" message +identifiers unrelated to their default text: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationString + ts = TranslationString('add-number', default='Add ${number}', + domain='form', mapping={'number':1}) + +When default text is used, Default text objects may contain +replacement values. + +.. index:: + single: translation string factory + +Using the ``TranslationStringFactory`` Class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Another way to generate a translation string is to use the +:attr:`repoze.bfg.i18n.TranslationStringFactory` object. This object +is a *translation string factory*. Basically a translation string +factory presets the ``domain`` value of any :term:`translation string` +generated by using it. For example: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationStringFactory + _ = TranslationStringFactory('bfg') + ts = _('Add ${number}', msgid='add-number', mapping={'number':1}) + +.. note:: We assigned the translation string factory to the name + ``_``. This is a convention which will be supported by translation + file generation tools. + +After assigning ``_`` to the result of a +:func:`repoze.bfg.i18n.TranslationStringFactory`, the subsequent +result of calling ``_`` will be a +:class:`repoze.bfg.i18n.TranslationString` instance. Even though a +``domain`` value was not passed to ``_` (as would have been necessary +if the :class:`repoze.bfg.i18n.TranslationString` constructor were +used instead of a translation string factory), the ``domain`` +attribute of the resulting translation string will be ``bfg``. As a +result, the previous code example is completely equivalent (except for +spelling) to: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationString as _ + ts = _('Add ${number}', msgid='add-number', mapping={'number':1}, + domain='bfg') + +You can set up your own translation string factory much like the one +provided above by using the +:class:`repoze.bfg.i18n.TranslationStringFactory` class. For example, +if you'd like to create a translation string factory which presets the +``domain`` value of generated translation strings to ``form``, you'd +do something like this: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import TranslationStringFactory + _ = TranslationStringFactory('form') + ts = _('Add ${number}', msgid='add-number', mapping={'number':1}) + +Creating a unique domain for your application via a translation string +factory is best practice. Using your own unique translation domain +allows another person to reuse your application without needing to +merge your translation files with his own. Instead, he can just +include your package's :term:`translation directory` via the +:meth:`repoze.bfg.configuration.Configurator.add_translation_dirs` +method. + +.. note:: + + For people familiar with Zope internationalization, a + TranslationStringFactory is a lot like a + ``zope.i18nmessageid.MessageFactory`` object. It is not a + subclass, however. + +.. index:: + single: gettext + single: translation directories + +Working With ``gettext`` Translation Files +------------------------------------------ + +Once your application source code files and templates are marked up +with translation markers, you can work on translations. + +.. note:: + + The steps used to work with gettext translation files in + :mod:`repoze.bfg` are very similar to the steps supported by + `Pylons internationalization + <http://wiki.pylonshq.com/display/pylonsdocs/Internationalization+and+Localization>`_. + +.. index:: + single: Babel + +.. _installing_babel: + +Installing Babel +~~~~~~~~~~~~~~~~ + +In order for the commands related to working with ``gettext`` +translation files to work properly, you will need to have +:term:`Babel` installed into the same environment in which +:mod:`repoze.bfg` is installed. + +Installation on UNIX +++++++++++++++++++++ + +If the :term:`virtualenv` into which you've installed your +:mod:`repoze.bfg` application lives in ``/my/virtualenv``, you can +install Babel like so: + +.. code-block:: bash + + $ cd /my/virtualenv + $ bin/easy_install Babel + +Installation on Windows ++++++++++++++++++++++++ + +If the :term:`virtualenv` into which you've installed your +:mod:`repoze.bfg` application lives in ``C:\my\virtualenv``, you can +install Babel like so: + +.. code-block:: bash + + C> cd \my\virtualenv + C> bin\easy_install Babel + +.. index:: + pair: extracting; messages + +Extracting Messages from Code and Templates +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once :term:`Babel` is installed, you may extract a message catalog +template from the code and :term:`Chameleon` templates which reside in +your application:: + + $ cd /place/where/myapplication/setup.py/lives + $ mkdir -p myapplication/locale + $ python setup.py extract_messages + +The message catalog template will end up in +``myapplication/locale/myapplication.pot``. + +XXX finish, including hair about Chameleon template scraping + +.. index:: + pair: initalizing; message catalog + +Initializaing a Message Catalog File +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Initialize a message catalog for a specific locale from a pregenerated +``.pot`` template:: + + $ cd /place/where/myapplication/setup.py/lives + $ python setup.py init_catalog -l es + +The message catalog ``.po`` file will end up in +``myapplication/locale/es/LC_MESSAGES/myapplication.po``. + +XXX finish + +.. index:: + pair: updating; message catalog + +Updating a Catalog File +~~~~~~~~~~~~~~~~~~~~~~~ + +Update ``.po`` files based on changes to the ``.pot`` file:: + + $ cd /place/where/myapplication/setup.py/lives + $ python setup.py update_catalog + +XXX finish + +.. index:: + pair: compiling; message catalog + +Compiling a Message Catalog File +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Compile ``.po`` files to ``.mo`` files:: + + $ cd /place/where/myapplication/setup.py/lives + $ python setup.py compile_catalog + +XXX finish + +.. index:: + single: localizer + single: get_localizer + +Using a Localizer +----------------- + +A :term:`localizer` is an object that allows you to perform +translation or pluralization "by hand" in an application. You may use +the :func:`repoze.bfg.i18n.get_localizer` function to obtain a +:term:`localizer`. :func:`repoze.bfg.i18n.get_localizer`. This +function will return either the localizer object implied by the active +:term:`locale negotiator` or a default localizer object if no explicit +locale negotiator is registered. + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import get_localizer + + def aview(request): + locale = get_localizer(request) + +.. index:: + single: translating (i18n) + +.. _performing_a_translation: + +Performing a Translation +~~~~~~~~~~~~~~~~~~~~~~~~ + +A :term:`localizer` has a ``translate`` method which accepts either a +:term:`translation string` or a Unicode string and which returns a +Unicode object representing the translation. So, generating a +translation in a view component of an application might look like so: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import get_localizer + from repoze.bfg.i18n import TranslationString + + ts = TranslationString('Add ${number}', mapping={'number':1}, domain='bfg') + + def aview(request): + localizer = get_localizer(request) + translated = localizer.translate(ts) # translation string + # ... use translated ... + +The :func:`repoze.bfg.i18n.get_localizer` function will return a +:class:`repoze.bfg.i18n.Localizer` object bound to the locale name +represented by the request. The translation returned from its +:meth:`repoze.bfg.i18n.Localizer.translate` method will depend on the +``domain`` attribute of the provided translation string as well as the +locale of the localizer. + +.. note:: If you're using :term:`Chameleon` templates, you don't need + to pre-translate translation strings this way. See + :ref:`chameleon_translation_strings`. + +.. index:: + single: pluralizing (i18n) + +.. _performing_a_pluralization: + +Performing a Pluralization +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A :term:`localizer` has a ``pluralize`` method with the following +signature: + +.. code-block:: python + :linenos: + + def pluralize(singular, plural, n, domain=None, mapping=None): + ... + +The ``singular`` and ``plural`` arguments should each be a Unicode +value representing a :term:`message identifier`. ``num`` should be an +integer. ``domain`` should be a :term:`translation domain`, and +``mapping`` should be a dictionary that is used for *replacement +value* interpolation of the translated string. If ``num`` is plural +for the current locale, ``pluralize`` will return a Unicode +translation for the message id ``plural``, otherwise it will return a +Unicode translation for the message id ``singular``. + +The arguments provided as ``singular`` and/or ``plural`` may also be +:term:`translation string` objects, but the domain and mapping +information attached to those object is ignored. + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import get_localizer + + def aview(request): + localizer = get_localizer(request) + translated = localizer.pluralize('Item', 'Items', 1, 'mydomain') + # ... use translated ... + +.. index:: + single: locale name + single: get_locale_name + single: negotiate_locale_name + +.. _obtaining_the_locale_name: + +Obtaining the Locale Name for a Request +--------------------------------------- + +You can obtain the locale name related to a request by using the +:func:`repoze.bfg.i18n.get_locale_name` function. + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import get_locale_name + + def aview(request): + locale_name = get_locale_name(request) + +This returns the locale name negotiated by the currently active +:term:`locale negotiator` or the default locale name (usually +``en``) if no locale negotiator is configured. + +Once :func:`repoze.bfg.i18n.get_locale_name` has run once, the local +name is stored on the request object it is passed. Subsequent calls +to :func:`repoze.bfg.i18n.get_locale_name` will return the stored +locale name without invoking the :term:`locale negotiator`. To +avoid this caching, you can use the +:func:`repoze.bfg.i18n.negotiate_locale_name` function: + +.. code-block:: python + :linenos: + + from repoze.bfg.i18n import negotiate_locale_name + + def aview(request): + locale_name = negotiate_locale_name(request) + +You can also obtain the locale name related to a request using the +``locale_name`` attribute of a :term:`localizer`. + + from repoze.bfg.i18n import get_localizer + + def aview(request): + localizer = get_localizer(request) + locale_name = localizer.locale_name + +Obtaining the locale name as an attribute of a localizer is equivalent +to obtaining a locale name by calling the +:func:`repoze.bfg.i18n.get_locale_name` function. + +.. index:: + single: date and currency formatting (i18n) + single: Babel + +Performing Date Formatting and Currency Formatting +-------------------------------------------------- + +:mod:`repoze.bfg` does not itself perform date and currency formatting +for different locales. However, :term:`Babel` can help you do this +via the :class:`babel.core.Locale` class. The `Babel documentation +for this class +<http://babel.edgewall.org/wiki/ApiDocs/babel.core#babel.core:Locale>`_ +provides minimal information about how to perform date and currency +related locale operations. See :ref:`installing_babel` for +information about how to install Babel. + +The :class:`babel.core.Locale` class requires a :term:`locale name` as +an argument to its constructor. You can use :mod:`repoze.bfg` APIs to +obtain the locale name for a request to pass to the +:class:`babel.core.Locale` constructor; see +:ref:`obtaining_the_locale_name`. For example: + +.. code-block:: python + :linenos: + + from babel.core import Locale + from repoze.bfg.i18n import get_locale_name + + def aview(request): + locale_name = get_locale_name(request) + locale = Locale(locale_name) + +.. index:: + pair: translation strings; Chameleon + +.. _chameleon_translation_strings: + +Chameleon Template Support for Translation Strings +-------------------------------------------------- + +When a :term:`translation string` is used as the subject of textual +rendering by a :term:`Chameleon` template renderer, it will +automatically be translated to the requesting user's language if a +suitable translation exists. This is true of both the ZPT and text +variants of the Chameleon template renderers. + +For example, in a Chameleon ZPT template, the translation string +represented by "some_translation_string" in each example below will go +through translation before being rendered: + +.. code-block:: xml + :linenos: + + <span tal:content="some_translation_string"/> + +.. code-block:: xml + :linenos: + + <span tal:replace="some_translation_string"/> + +.. code-block:: xml + :linenos: + + <span>${some_translation_string}</span> + +.. code-block:: xml + :linenos: + + <a tal:attributes="href some_translation_string">Click here</a> + XXX this appears to not yet work as of Chameleon 1.2.3 + +The features represented by attributes of the ``i18n`` namespace of +Chameleon will also consult the :mod:`repoze.bfg` translations. +See +`http://chameleon.repoze.org/docs/latest/i18n.html#the-i18n-namespace +<http://chameleon.repoze.org/docs/latest/i18n.html#the-i18n-namespace>`_. + +.. note:: + + Unlike when Chameleon is used outside of :mod:`repoze.bfg`, when it + is used *within* :mod:`repoze.bfg`, it does not support use of the + ``zope.i18n`` translation framework. Applications which use + :mod:`repoze.bfg` should use the features documented in this + chapter rather than ``zope.i18n``. + +Third party :mod:`repoze.bfg` template renderers might not provide +this support out of the box and may need special code to do an +equivalent. For those, you can always use the more manual translation +facility described in :ref:`performing_a_translation`. + +.. index:: + single: localization deployment settings + single: default_locale_name + +.. _localization_deployment_settings: + +Localization-Related Deployment Settings +---------------------------------------- + +A :mod:`repoze.bfg` application will have a ``default_locale_name`` +setting. This value represents the default locale name when no +:term:`locale negotiator` is registered. Pass it to the +:mod:`repoze.bfg.configuration.Configurator` constructor at startup +time: + +.. code-block:: python + :linenos: + + from repoze.bfg.configuration import Configurator + config = Configurator(settings={'default_locale_name':'de'}) + +You may alternately supply a ``default_locale_name`` via an +application's Paster ``.ini`` file: + +.. code-block:: ini + :linenos: + + [app:main] + use = egg:MyProject#app + reload_templates = true + debug_authorization = false + debug_notfound = false + default_locale_name = de + +If this value is not supplied via the Configurator constructor or via +a Paste onfig file, it will default to ``en``. + +If this setting is supplied within the :mod:`repoze.bfg` application +``.ini`` file, it will be available as a settings key: + +.. code-block:: python + :linenos: + + from repoze.bfg.setttings import get_settings + settings = get_settings() + default_locale_name = settings['default_locale_name'] + +.. index:: + pair: translation; activating + pair: locale; negotiator + single: translation directory + +.. _activating_translation: + +Activating Translation +---------------------- + +By default, a :mod:`repoze.bfg` application performs no translation. +To turn translation on, you must do both of these two things: + +- Add at least one :term:`translation directory` to your application. + +- Configure a :term:`locale negotiator` into your application's + configuration. + +:term:`gettext` is the underlying machinery behind the +:mod:`repoze.bfg` translation machinery. A translation directory is a +directory organized to be useful to :term:`gettext`. A translation +directory usually includes a listing of language directories, each of +which itself includes an ``LC_MESSAGES`` directory. Each +``LC_MESSAGES`` directory should contain one or more ``.mo`` files. +Each ``.mo`` file represents a :term:`message catalog`, which is used +to provide translations to your application. + +A locale negotiator is a bit of code which accepts a request and which +returns a :term:`locale name`. It is consulted when +:meth:`repoze.bfg.i18n.Localizer.translate` +:meth:`repoze.bfg.i18n.Localizer.pluralize` is invoked. It is also +consulted when :func:`repoze.bfg.i18n.get_locale_name` or +:func:`repoze.bfg.i18n.negotiate_locale_name` is invoked. + +At the time of this writing, only one (very weak) built-in locale +negotiator implementation named +:class:`repoze.bfg.i18n.default_locale_negotiator` ships as part of +the :mod:`repoze.bfg` software. This negotiator looks only at the the +``request.params['locale']`` value to determine the locale name. You +can provide your own locale negotiator function as required. + +Adding a Translation Directory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You may add a :term:`translation directory` to your application's +configuration either imperatively or via ZCML. Adding a translation +directory registers all of its constituent :term:`message catalog` +files (all of the ``.mo`` files found within all ``LC_MESSAGES`` +directories within each locale directory in the translation directory) +within your :mod:`repoze.bfg` application to be available to use for +translation services. + +Imperative +++++++++++ + +You can add a translation directory imperatively by using the +:meth:`repoze.bfg.configuration.Configurator.add_translation_dirs` +during application startup. + +For example: + +.. code-block:: python + :linenos: + + from repoze.bfg.configuration import Configurator + from repoze.bfg.i18n import default_locale_negotiator + config = Configurator(locale_negotiator=default_locale_negotiator) + config.begin() + config.add_translation_dirs('my.application:locale/', + 'another.application:locale/') + config.end() + +A message catalog in a translation directory added via +:meth:`repoze.bfg.configuration.Configurator.add_translation_dirs` +will be merged into translations from an message catalog added earlier +if both translation directories contain translations for the same +locale and :term:`translation domain`. + +ZCML +++++ + +You can add a translation directory via ZCML by using the +:ref:`translationdir_directive` ZCML directive: + +.. code-block:: xml + :linenos: + + <translationdir dir="my.application:locale/"/> + +A message catalog in a translation directory added via +:ref:`translationdir_directive` will be merged into translations from +an message catalog added earlier if both translation directories +contain translations for the same locale and :term:`translation +domain`. + +.. _adding_a_locale_negotiator: + +Adding a Locale Negotiator +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You may add a :term:`locale negotiator` either imperatively or via +ZCML. A locale negotiator informs the operation of a +:term:`localizer` by telling it what :term:`locale name` is related to +a particular request. See :ref:`creating_a_locale_negotiator`. + +Imperative +++++++++++ + +Pass an object which can act as the negotiator as the +``locale_negotiator`` argument of the +:class:`repoze.bfg.configuration.Configurator` instance during +application startup. + +For example: + +.. code-block:: python + :linenos: + + from repoze.bfg.configuration import Configurator + from repoze.bfg.i18n import default_locale_negotiator + config = Configurator(locale_negotiator=default_locale_negotiator) + +Alternately, use the +:meth:`repoze.bfg.configuration.Configurator.set_locale_negotiator` +method. + +For example: + +.. code-block:: python + :linenos: + + from repoze.bfg.configuration import Configurator + from repoze.bfg.i18n import default_locale_negotiator + config = Configurator() + config.begin() + config.set_locale_negotiator(default_locale_negotiator) + config.end() + +ZCML +++++ + +You can add a translation directory via ZCML by using the +:ref:`localenegotiator_directive` ZCML directive: + +.. code-block:: xml + :linenos: + + <localenegotiator negotiator="repoze.bfg.i18n.default_locale_negotiator"/> + +.. _creating_a_locale_negotiator: + +Creating a Locale Negotiator +---------------------------- + +A :term:`locale negotiator` is simply a callable which accepts a +request and returns a single :term:`locale name`. Here's an +implementation of a simple locale negotiator: + +.. code-block:: python + :linenos: + + def default_locale_negotiator(request): + locale_name = request.params.get('locale') + if locale_name is None: + settings = get_settings() or {} + locale_name = settings.get('default_locale_name', 'en') + return locale_name + +Locale negotiation can be complex. Your application may require a +policy-laden locale negotiator policy, so you can write your own and +supply it to an application configuration as per +:ref:`adding_a_locale_negotiator`. diff --git a/docs/narr/project.rst b/docs/narr/project.rst index 89bc5691b..dfac238b5 100644 --- a/docs/narr/project.rst +++ b/docs/narr/project.rst @@ -421,6 +421,7 @@ structure:: | |-- tests.py | `-- views.py |-- MyProject.ini + |-- setup.cfg `-- setup.py The ``MyProject`` :term:`Project` @@ -441,6 +442,9 @@ describe, run, and test your application. #. ``MyProject.ini`` is a :term:`PasteDeploy` configuration file that can be used to execute your application. +#. ``setup.cfg`` is a :term:`setuptools` configuration file used by + ``setup.py``. + #. ``setup.py`` is the file you'll use to test and distribute your application. It is a standard :term:`setuptools` ``setup.py`` file. @@ -633,6 +637,21 @@ tarball to other people who want to use your application. you rerun ``setup.py sdist``, all files checked into the version control system will be included in the tarball. +``setup.cfg`` +~~~~~~~~~~~~~ + +The ``setup.cfg`` file is a :term:`setuptools` configuration file. It +contains various settings related to testing and internationalization: + +Our generated ``setup.cfg`` looks like this: + +.. literalinclude:: MyProject/setup.cfg + :linenos: + +The values in the default setup file allow various commonly-used +internationalization commands and testing commands to work more +smoothly. + .. index:: single: package diff --git a/docs/narr/views.rst b/docs/narr/views.rst index a24e4b7b5..eebaa63de 100644 --- a/docs/narr/views.rst +++ b/docs/narr/views.rst @@ -840,6 +840,11 @@ In all cases, the message provided to the exception constructor is made available to the view which :mod:`repoze.bfg` invokes as ``request.exception.args[0]``. +.. index:: + single: exception views + +.. _exception_views: + Exception Views ~~~~~~~~~~~~~~~~ diff --git a/docs/zcml.rst b/docs/zcml.rst index 0e45f7692..e1bfc4f4b 100644 --- a/docs/zcml.rst +++ b/docs/zcml.rst @@ -16,6 +16,7 @@ directive documentation is organized alphabetically by directive name. zcml/configure zcml/forbidden zcml/include + zcml/localenegotiator zcml/notfound zcml/remoteuserauthenticationpolicy zcml/renderer @@ -25,5 +26,6 @@ directive documentation is organized alphabetically by directive name. zcml/scan zcml/static zcml/subscriber + zcml/translationdir zcml/utility zcml/view diff --git a/docs/zcml/localenegotiator.rst b/docs/zcml/localenegotiator.rst new file mode 100644 index 000000000..59dcf32c2 --- /dev/null +++ b/docs/zcml/localenegotiator.rst @@ -0,0 +1,41 @@ +.. _localenegotiator_directive: + +``localenegotiator`` +-------------------- + +Set the :term:`locale negotiator` for the current configurator to +support localization of text. + +.. note: This ZCML directive is new as of :mod:`repoze.bfg` version 1.3. + +Attributes +~~~~~~~~~~ + +``negotiator`` + + The :term:`dotted Python name` to a :term:`locale negotiator` + implementation. This attribute is required. If it begins with a + dot (``.``), the name will be considered relative to the directory + in which the ZCML file which contains this directive lives. + +Example +~~~~~~~ + +.. code-block:: xml + :linenos: + + <localenegotiator + negotiator="some.package.module.my_locale_negotiator" + /> + +Alternatives +~~~~~~~~~~~~ + +Use :meth:`repoze.bfg.configuration.Configurator.set_locale_negotiator` +method instance during initial application setup. + +See Also +~~~~~~~~ + +See also :ref:`activating_translation`. + diff --git a/docs/zcml/translationdir.rst b/docs/zcml/translationdir.rst new file mode 100644 index 000000000..5c69cb737 --- /dev/null +++ b/docs/zcml/translationdir.rst @@ -0,0 +1,66 @@ +.. _translationdir_directive: + +``translationdir`` +------------------ + +Add a :term:`gettext` :term:`translation directory` to the current +configuration for use in localization of text. + +.. note: This ZCML directive is new as of :mod:`repoze.bfg` version 1.3. + +Attributes +~~~~~~~~~~ + +``dir`` + The path to the translation directory. This path may either be 1) + absolute (e.g. ``/foo/bar/baz``) 2) Python-package-relative + (e.g. ``packagename:foo/bar/baz``) or 3) relative to the package + directory in which the ZCML file which contains the directive + (e.g. ``foo/bar/baz``). + +Example 1 +~~~~~~~~~ + +.. code-block:: xml + :linenos: + + <!-- relative to configure.zcml file --> + + <translationdir + dir="locale" + /> + +Example 2 +~~~~~~~~~ + +.. code-block:: xml + :linenos: + + <!-- relative to another package --> + + <translationdir + dir="another.package:locale" + /> + +Example 3 +~~~~~~~~~ + +.. code-block:: xml + :linenos: + + <!-- an absolute directory name --> + + <translationdir + dir="/usr/share/locale" + /> + +Alternatives +~~~~~~~~~~~~ + +Use :meth:`repoze.bfg.configuration.Configurator.add_translation_dirs` +method instance during initial application setup. + +See Also +~~~~~~~~ + +See also :ref:`activating_translation`. diff --git a/repoze/bfg/chameleon_text.py b/repoze/bfg/chameleon_text.py index f1dc8e3aa..deb1cc43d 100644 --- a/repoze/bfg/chameleon_text.py +++ b/repoze/bfg/chameleon_text.py @@ -22,6 +22,7 @@ except ImportError: # pragma: no cover from repoze.bfg.interfaces import IResponseFactory from repoze.bfg.interfaces import ITemplateRenderer +from repoze.bfg.interfaces import IChameleonTranslate from repoze.bfg.decorator import reify from repoze.bfg.renderers import template_renderer_factory @@ -50,7 +51,12 @@ class TextTemplateRenderer(object): def template(self): settings = get_settings() auto_reload = settings and settings['reload_templates'] - return TextTemplateFile(self.path, auto_reload=auto_reload) + reg = get_current_registry() + translate = None + if reg is not None: + translate = reg.queryUtility(IChameleonTranslate) + return TextTemplateFile(self.path, auto_reload=auto_reload, + translate=translate) def implementation(self): return self.template diff --git a/repoze/bfg/chameleon_zpt.py b/repoze/bfg/chameleon_zpt.py index f597ebd5f..687a11305 100644 --- a/repoze/bfg/chameleon_zpt.py +++ b/repoze/bfg/chameleon_zpt.py @@ -13,6 +13,7 @@ except ImportError: # pragma: no cover def __init__(self, *arg, **kw): raise ImportError, exc, tb +from repoze.bfg.interfaces import IChameleonTranslate from repoze.bfg.interfaces import IResponseFactory from repoze.bfg.interfaces import ITemplateRenderer @@ -33,7 +34,12 @@ class ZPTTemplateRenderer(object): def template(self): settings = get_settings() auto_reload = settings and settings['reload_templates'] - return PageTemplateFile(self.path, auto_reload=auto_reload) + reg = get_current_registry() + translate = None + if reg is not None: + translate = reg.queryUtility(IChameleonTranslate) + return PageTemplateFile(self.path, auto_reload=auto_reload, + translate=translate) def implementation(self): return self.template diff --git a/repoze/bfg/configuration.py b/repoze/bfg/configuration.py index 4673479da..9ebb62a01 100644 --- a/repoze/bfg/configuration.py +++ b/repoze/bfg/configuration.py @@ -8,6 +8,8 @@ import inspect from webob import Response import venusian +from translationstring import ChameleonTranslate + from zope.configuration import xmlconfig from zope.interface import Interface @@ -17,9 +19,11 @@ from zope.interface import implements from repoze.bfg.interfaces import IAuthenticationPolicy from repoze.bfg.interfaces import IAuthorizationPolicy +from repoze.bfg.interfaces import IChameleonTranslate from repoze.bfg.interfaces import IDebugLogger from repoze.bfg.interfaces import IDefaultRootFactory from repoze.bfg.interfaces import IExceptionViewClassifier +from repoze.bfg.interfaces import ILocaleNegotiator from repoze.bfg.interfaces import IMultiView from repoze.bfg.interfaces import IPackageOverrides from repoze.bfg.interfaces import IRendererFactory @@ -31,6 +35,7 @@ from repoze.bfg.interfaces import IRoutesMapper from repoze.bfg.interfaces import ISecuredView from repoze.bfg.interfaces import ISettings from repoze.bfg.interfaces import ITemplateRenderer +from repoze.bfg.interfaces import ITranslationDirectories from repoze.bfg.interfaces import ITraverser from repoze.bfg.interfaces import IView from repoze.bfg.interfaces import IViewClassifier @@ -45,8 +50,10 @@ from repoze.bfg.events import WSGIApplicationCreatedEvent from repoze.bfg.exceptions import Forbidden from repoze.bfg.exceptions import NotFound from repoze.bfg.exceptions import ConfigurationError +from repoze.bfg.i18n import get_localizer from repoze.bfg.log import make_stream_logger from repoze.bfg.path import caller_package +from repoze.bfg.path import package_path from repoze.bfg.registry import Registry from repoze.bfg.request import route_request_iface from repoze.bfg.resource import PackageOverrides @@ -54,6 +61,7 @@ from repoze.bfg.resource import resolve_resource_spec from repoze.bfg.settings import Settings from repoze.bfg.static import StaticRootFactory from repoze.bfg.threadlocal import get_current_registry +from repoze.bfg.threadlocal import get_current_request from repoze.bfg.threadlocal import manager from repoze.bfg.traversal import traversal_path from repoze.bfg.traversal import DefaultRootFactory @@ -135,14 +143,16 @@ class Configurator(object): logs to stderr will be used. If it is passed, it should be an instance of the :class:`logging.Logger` (PEP 282) standard library class. The debug logger is used by :mod:`repoze.bfg` itself to - log warnings and authorization debugging information. """ - + log warnings and authorization debugging information. + + """ manager = manager # for testing injection venusian = venusian # for testing injection def __init__(self, registry=None, package=None, settings=None, root_factory=None, authentication_policy=None, authorization_policy=None, renderers=DEFAULT_RENDERERS, - debug_logger=None): + debug_logger=None, + locale_negotiator=None): self.package = package or caller_package() self.registry = registry if registry is None: @@ -154,7 +164,8 @@ class Configurator(object): authentication_policy=authentication_policy, authorization_policy=authorization_policy, renderers=renderers, - debug_logger=debug_logger) + debug_logger=debug_logger, + locale_negotiator=locale_negotiator) def _set_settings(self, mapping): settings = Settings(mapping or {}) @@ -270,7 +281,8 @@ class Configurator(object): def setup_registry(self, settings=None, root_factory=None, authentication_policy=None, authorization_policy=None, - renderers=DEFAULT_RENDERERS, debug_logger=None): + renderers=DEFAULT_RENDERERS, debug_logger=None, + locale_negotiator=None): """ When you pass a non-``None`` ``registry`` argument to the :term:`Configurator` constructor, no initial 'setup' is performed against the registry. This is because the registry @@ -304,6 +316,8 @@ class Configurator(object): self.add_renderer(name, renderer) self.set_notfound_view(default_notfound_view) self.set_forbidden_view(default_forbidden_view) + if locale_negotiator: + registry.registerUtility(locale_negotiator, ILocaleNegotiator) # getSiteManager is a unit testing dep injection def hook_zca(self, getSiteManager=None): @@ -1225,10 +1239,6 @@ class Configurator(object): found via context-finding or ``None`` if no context could be found. The exception causing the registered view to be called is however still available as ``request.exception``. - .. warning:: This method has been deprecated in - :mod:`repoze.bfg` 1.3. See - :ref:`changing_the_forbidden_view` to see how a not found - view should be registered in :mod:`repoze.bfg` 1.3+. The ``view`` argument should be a :term:`view callable`. @@ -1299,6 +1309,71 @@ class Configurator(object): return self.add_view(bwcompat_view, context=NotFound, wrapper=wrapper, _info=_info) + def set_locale_negotiator(self, negotiator): + """ + Set the :term:`locale negotiator` for this application. The + :term:`locale negotiator` is a callable which accepts a + :term:`request` object and which returns a :term:`locale + name`. Later calls to this method override earlier calls; + there can be only one locale negotiator active at a time + within an application. See :ref:`activating_translation` for + more information. + + .. note: This API is new as of :mod:`repoze.bfg` version 1.3. + """ + self.registry.registerUtility(negotiator, ILocaleNegotiator) + + def add_translation_dirs(self, *specs): + """ Add one or more :term:`translation directory` paths to the + current configuration state. The ``specs`` argument is a + sequence that may contain absolute directory paths + (e.g. ``/usr/share/locale``) or :term:`resource specification` + names naming a directory path (e.g. ``some.package:locale``) + or a combination of the two. + + Example: + + .. code-block:: python + + add_translations_dirs('/usr/share/locale', 'some.package:locale') + + .. note: This API is new as of :mod:`repoze.bfg` version 1.3. + """ + for spec in specs: + + package_name, filename = self._split_spec(spec) + if package_name is None: # absolute filename + directory = filename + else: + __import__(package_name) + package = sys.modules[package_name] + directory = os.path.join(package_path(package), filename) + + if not os.path.isdir(os.path.realpath(directory)): + raise ConfigurationError('"%s" is not a directory' % directory) + + tdirs = self.registry.queryUtility(ITranslationDirectories) + if tdirs is None: + tdirs = [] + self.registry.registerUtility(tdirs, ITranslationDirectories) + + tdirs.insert(0, directory) + + if specs: + + # We actually only need an IChameleonTranslate function + # utility to be registered zero or one times. We register the + # same function once for each added translation directory, + # which does too much work, but has the same effect. + + def translator(msg): + request = get_current_request() + localizer = get_localizer(request) + return localizer.translate(msg) + + ctranslate = ChameleonTranslate(translator) + self.registry.registerUtility(ctranslate, IChameleonTranslate) + def add_static_view(self, name, path, cache_max_age=3600, _info=u''): """ Add a view used to render static resources to the current configuration state. diff --git a/repoze/bfg/i18n.py b/repoze/bfg/i18n.py new file mode 100644 index 000000000..e3b276331 --- /dev/null +++ b/repoze/bfg/i18n.py @@ -0,0 +1,306 @@ +import gettext +import os + +from translationstring import Translator +from translationstring import Pluralizer +from translationstring import TranslationString # API +from translationstring import TranslationStringFactory # API + +TranslationString = TranslationString # PyFlakes +TranslationStringFactory = TranslationStringFactory # PyFlakes + +from repoze.bfg.interfaces import ILocalizer +from repoze.bfg.interfaces import ITranslationDirectories +from repoze.bfg.interfaces import ILocaleNegotiator + +from repoze.bfg.settings import get_settings +from repoze.bfg.threadlocal import get_current_registry + +class Localizer(object): + """ + An object providing translation and pluralizations related to + the current request's locale name. A + :class:`repoze.bfg.i18n.Localizer` object is created using the + :func:`repoze.bfg.i18n.get_localizer` function. + """ + def __init__(self, locale_name, translations): + self.locale_name = locale_name + self.translations = translations + self.pluralizer = None + self.translator = None + + def translate(self, tstring, domain=None, mapping=None): + """ + Translate a :term:`translation string` to the current language + and interpolate any *replacement markers* in the result. The + ``translate`` method accepts three arguments: ``tstring`` + (required), ``domain`` (optional) and ``mapping`` (optional). + When called, it will translate the ``tstring`` translation + string to a ``unicode`` object using the current locale. If + the current locale could not be determined, the result of + interpolation of the default value is returned. The optional + ``domain`` argument can be used to specify or override the + domain of the ``tstring`` (useful when ``tstring`` is a normal + string rather than a translation string). The optional + ``mapping`` argument can specify or override the ``tstring`` + interpolation mapping, useful when the ``tstring`` argument is + a simple string instead of a translation string. + + Example:: + + from repoze.bfg.18n import TranslationString + ts = TranslationString('Add ${item}', domain='mypackage', + mapping={'item':'Item'}) + translated = localizer.translate(ts) + + Example:: + + translated = localizer.translate('Add ${item}', domain='mypackage', + mapping={'item':'Item'}) + + """ + if self.translator is None: + self.translator = Translator(self.translations) + return self.translator(tstring, domain=domain, mapping=mapping) + + def pluralize(self, singular, plural, n, domain=None, mapping=None): + """ + Return a Unicode string translation by using two + :term:`message identifier` objects as a singular/plural pair + and an ``n`` value representing the number that appears in the + message using gettext plural forms support. The ``singular`` + and ``plural`` objects passed may be translation strings or + unicode strings. ``n`` represents the number of elements. + ``domain`` is the translation domain to use to do the + pluralization, and ``mapping`` is the interpolation mapping + that should be used on the result. Note that if the objects + passed are translation strings, their domains and mappings are + ignored. The domain and mapping arguments must be used + instead. If the ``domain`` is not supplied, a default domain + is used (usually ``messages``). + + Example:: + + num = 1 + translated = localizer.pluralize('Add ${num} item', + 'Add ${num} items', + num, + mapping={'num':num}) + + + """ + if self.pluralizer is None: + self.pluralizer = Pluralizer(self.translations) + return self.pluralizer(singular, plural, n, domain=domain, + mapping=mapping) + + +def default_locale_negotiator(request): + """ The default :term:`locale negotiator`. Returns a locale + name based on ``request.params.get('locale')`` or the + ``default_locale_name`` settings option; or ``en`` if all else + fails.""" + locale_name = request.params.get('locale') + if locale_name is None: + settings = get_settings() or {} + locale_name = settings.get('default_locale_name', 'en') + return locale_name + +def negotiate_locale_name(request): + """ Negotiate and return the :term:`locale name` associated with + the current request (never cached).""" + try: + registry = request.registry + except AttributeError: + registry = get_current_registry() + negotiator = registry.queryUtility(ILocaleNegotiator) + if negotiator is None: + settings = get_settings() or {} + locale_name = settings.get('default_locale_name', 'en') + else: + locale_name = negotiator(request) + return locale_name + +def get_locale_name(request): + """ Return the :term:`locale name` associated with the current + request (possibly cached).""" + locale_name = getattr(request, 'bfg_locale_name', None) + if locale_name is None: + locale_name = negotiate_locale_name(request) + request.bfg_locale_name = locale_name + return locale_name + +def get_localizer(request): + """ Retrieve a :class:`repoze.bfg.i18n.Localizer` object + corresponding to the current request's locale name. """ + localizer = getattr(request, 'bfg_localizer', None) + + if localizer is None: + # no locale object cached on request + try: + registry = request.registry + except AttributeError: + registry = get_current_registry() + + current_locale_name = get_locale_name(request) + localizer = registry.queryUtility(ILocalizer, name=current_locale_name) + + if localizer is None: + # no localizer utility registered yet + translations = Translations() + translations._catalog = {} + tdirs = registry.queryUtility(ITranslationDirectories, default=[]) + for tdir in tdirs: + locale_dirs = [ (lname, os.path.join(tdir, lname)) for lname in + os.listdir(tdir) ] + for locale_name, locale_dir in locale_dirs: + if locale_name != current_locale_name: + continue + messages_dir = os.path.join(locale_dir, 'LC_MESSAGES') + if not os.path.isdir(os.path.realpath(messages_dir)): + continue + for mofile in os.listdir(messages_dir): + mopath = os.path.realpath(os.path.join(messages_dir, + mofile)) + if mofile.endswith('.mo') and os.path.isfile(mopath): + mofp = open(mopath, 'rb') + domain = mofile[:-3] + dtrans = Translations(mofp, domain) + translations.add(dtrans) + + localizer = Localizer(locale_name=current_locale_name, + translations=translations) + registry.registerUtility(localizer, ILocalizer, + name=current_locale_name) + request.bfg_localizer = localizer + + return localizer + +class Translations(gettext.GNUTranslations, object): + """An extended translation catalog class (ripped off from Babel) """ + + DEFAULT_DOMAIN = 'messages' + + def __init__(self, fileobj=None, domain=DEFAULT_DOMAIN): + """Initialize the translations catalog. + + :param fileobj: the file-like object the translation should be read + from + """ + gettext.GNUTranslations.__init__(self, fp=fileobj) + self.files = filter(None, [getattr(fileobj, 'name', None)]) + self.domain = domain + self._domains = {} + + @classmethod + def load(cls, dirname=None, locales=None, domain=DEFAULT_DOMAIN): + """Load translations from the given directory. + + :param dirname: the directory containing the ``MO`` files + :param locales: the list of locales in order of preference (items in + this list can be either `Locale` objects or locale + strings) + :param domain: the message domain + :return: the loaded catalog, or a ``NullTranslations`` instance if no + matching translations were found + :rtype: `Translations` + """ + if locales is not None: + if not isinstance(locales, (list, tuple)): + locales = [locales] + locales = [str(l) for l in locales] + if not domain: + domain = cls.DEFAULT_DOMAIN + filename = gettext.find(domain, dirname, locales) + if not filename: + return gettext.NullTranslations() + return cls(fileobj=open(filename, 'rb'), domain=domain) + + def __repr__(self): + return '<%s: "%s">' % (type(self).__name__, + self._info.get('project-id-version')) + + def add(self, translations, merge=True): + """Add the given translations to the catalog. + + If the domain of the translations is different than that of the + current catalog, they are added as a catalog that is only accessible + by the various ``d*gettext`` functions. + + :param translations: the `Translations` instance with the messages to + add + :param merge: whether translations for message domains that have + already been added should be merged with the existing + translations + :return: the `Translations` instance (``self``) so that `merge` calls + can be easily chained + :rtype: `Translations` + """ + domain = getattr(translations, 'domain', self.DEFAULT_DOMAIN) + if merge and domain == self.domain: + return self.merge(translations) + + existing = self._domains.get(domain) + if merge and existing is not None: + existing.merge(translations) + else: + translations.add_fallback(self) + self._domains[domain] = translations + + return self + + def merge(self, translations): + """Merge the given translations into the catalog. + + Message translations in the specified catalog override any messages + with the same identifier in the existing catalog. + + :param translations: the `Translations` instance with the messages to + merge + :return: the `Translations` instance (``self``) so that `merge` calls + can be easily chained + :rtype: `Translations` + """ + if isinstance(translations, gettext.GNUTranslations): + self._catalog.update(translations._catalog) + if isinstance(translations, Translations): + self.files.extend(translations.files) + + return self + + def dgettext(self, domain, message): + """Like ``gettext()``, but look the message up in the specified + domain. + """ + return self._domains.get(domain, self).gettext(message) + + def ldgettext(self, domain, message): + """Like ``lgettext()``, but look the message up in the specified + domain. + """ + return self._domains.get(domain, self).lgettext(message) + + def dugettext(self, domain, message): + """Like ``ugettext()``, but look the message up in the specified + domain. + """ + return self._domains.get(domain, self).ugettext(message) + + def dngettext(self, domain, singular, plural, num): + """Like ``ngettext()``, but look the message up in the specified + domain. + """ + return self._domains.get(domain, self).ngettext(singular, plural, num) + + def ldngettext(self, domain, singular, plural, num): + """Like ``lngettext()``, but look the message up in the specified + domain. + """ + return self._domains.get(domain, self).lngettext(singular, plural, num) + + def dungettext(self, domain, singular, plural, num): + """Like ``ungettext()`` but look the message up in the specified + domain. + """ + return self._domains.get(domain, self).ungettext(singular, plural, num) + diff --git a/repoze/bfg/includes/meta.zcml b/repoze/bfg/includes/meta.zcml index 25894df71..5ecebd868 100644 --- a/repoze/bfg/includes/meta.zcml +++ b/repoze/bfg/includes/meta.zcml @@ -77,6 +77,18 @@ /> <meta:directive + name="translationdir" + schema="repoze.bfg.zcml.ITranslationDirDirective" + handler="repoze.bfg.zcml.translationdir" + /> + + <meta:directive + name="localenegotiator" + schema="repoze.bfg.zcml.ILocaleNegotiatorDirective" + handler="repoze.bfg.zcml.localenegotiator" + /> + + <meta:directive name="adapter" schema="repoze.bfg.zcml.IAdapterDirective" handler="repoze.bfg.zcml.adapter" diff --git a/repoze/bfg/interfaces.py b/repoze/bfg/interfaces.py index 40d29348c..09b639525 100644 --- a/repoze/bfg/interfaces.py +++ b/repoze/bfg/interfaces.py @@ -229,3 +229,20 @@ class IPackageOverrides(Interface): # VH_ROOT_KEY is an interface; its imported from other packages (e.g. # traversalwrapper) VH_ROOT_KEY = 'HTTP_X_VHM_ROOT' + +class IChameleonTranslate(Interface): + """ Internal interface representing a chameleon translate function """ + def __call__(msgid, domain=None, mapping=None, context=None, + target_language=None, default=None): + """ Translate a mess of arguments to a Unicode object """ + +class ILocalizer(Interface): + """ Localizer for a specific language """ + +class ILocaleNegotiator(Interface): + def __call__(request): + """ Return a locale name """ + +class ITranslationDirectories(Interface): + """ A list object representing all known translation directories + for an application""" diff --git a/repoze/bfg/paster_templates/alchemy/+project+.ini_tmpl b/repoze/bfg/paster_templates/alchemy/+project+.ini_tmpl index c3d79e08b..29fc7e806 100644 --- a/repoze/bfg/paster_templates/alchemy/+project+.ini_tmpl +++ b/repoze/bfg/paster_templates/alchemy/+project+.ini_tmpl @@ -6,6 +6,7 @@ use = egg:{{project}}#app reload_templates = true debug_authorization = false debug_notfound = false +default_locale_name = en db_string = sqlite:///%(here)s/{{package}}.db db_echo = false diff --git a/repoze/bfg/paster_templates/alchemy/setup.cfg_tmpl b/repoze/bfg/paster_templates/alchemy/setup.cfg_tmpl new file mode 100644 index 000000000..5bec29823 --- /dev/null +++ b/repoze/bfg/paster_templates/alchemy/setup.cfg_tmpl @@ -0,0 +1,27 @@ +[nosetests] +match=^test +nocapture=1 +cover-package={{package}} +with-coverage=1 +cover-erase=1 + +[compile_catalog] +directory = {{package}}/locale +domain = {{project}} +statistics = true + +[extract_messages] +add_comments = TRANSLATORS: +output_file = {{package}}/locale/{{project}}.pot +width = 80 + +[init_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale + +[update_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale +previous = true diff --git a/repoze/bfg/paster_templates/routesalchemy/+project+.ini_tmpl b/repoze/bfg/paster_templates/routesalchemy/+project+.ini_tmpl index 72c373fc2..c249172d3 100644 --- a/repoze/bfg/paster_templates/routesalchemy/+project+.ini_tmpl +++ b/repoze/bfg/paster_templates/routesalchemy/+project+.ini_tmpl @@ -6,6 +6,7 @@ use = egg:{{package}}#app reload_templates = true debug_authorization = false debug_notfound = false +default_locale_name = en db_string = sqlite:///%(here)s/{{package}}.db [pipeline:main] diff --git a/repoze/bfg/paster_templates/routesalchemy/setup.cfg_tmpl b/repoze/bfg/paster_templates/routesalchemy/setup.cfg_tmpl index 8bf5d22c1..5bec29823 100644 --- a/repoze/bfg/paster_templates/routesalchemy/setup.cfg_tmpl +++ b/repoze/bfg/paster_templates/routesalchemy/setup.cfg_tmpl @@ -4,3 +4,24 @@ nocapture=1 cover-package={{package}} with-coverage=1 cover-erase=1 + +[compile_catalog] +directory = {{package}}/locale +domain = {{project}} +statistics = true + +[extract_messages] +add_comments = TRANSLATORS: +output_file = {{package}}/locale/{{project}}.pot +width = 80 + +[init_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale + +[update_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale +previous = true diff --git a/repoze/bfg/paster_templates/starter/+project+.ini_tmpl b/repoze/bfg/paster_templates/starter/+project+.ini_tmpl index cc1e2aa12..15be80cf4 100644 --- a/repoze/bfg/paster_templates/starter/+project+.ini_tmpl +++ b/repoze/bfg/paster_templates/starter/+project+.ini_tmpl @@ -6,6 +6,7 @@ use = egg:{{project}}#app reload_templates = true debug_authorization = false debug_notfound = false +default_locale_name = en [server:main] use = egg:Paste#http diff --git a/repoze/bfg/paster_templates/starter/setup.cfg_tmpl b/repoze/bfg/paster_templates/starter/setup.cfg_tmpl new file mode 100644 index 000000000..5bec29823 --- /dev/null +++ b/repoze/bfg/paster_templates/starter/setup.cfg_tmpl @@ -0,0 +1,27 @@ +[nosetests] +match=^test +nocapture=1 +cover-package={{package}} +with-coverage=1 +cover-erase=1 + +[compile_catalog] +directory = {{package}}/locale +domain = {{project}} +statistics = true + +[extract_messages] +add_comments = TRANSLATORS: +output_file = {{package}}/locale/{{project}}.pot +width = 80 + +[init_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale + +[update_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale +previous = true diff --git a/repoze/bfg/paster_templates/zodb/+project+.ini_tmpl b/repoze/bfg/paster_templates/zodb/+project+.ini_tmpl index ca6fe99a2..51b4e1ab8 100644 --- a/repoze/bfg/paster_templates/zodb/+project+.ini_tmpl +++ b/repoze/bfg/paster_templates/zodb/+project+.ini_tmpl @@ -6,6 +6,7 @@ use = egg:{{project}}#app reload_templates = true debug_authorization = false debug_notfound = false +default_locale_name = en zodb_uri = file://%(here)s/Data.fs?connection_cache_size=20000 [pipeline:main] diff --git a/repoze/bfg/paster_templates/zodb/setup.cfg_tmpl b/repoze/bfg/paster_templates/zodb/setup.cfg_tmpl new file mode 100644 index 000000000..5bec29823 --- /dev/null +++ b/repoze/bfg/paster_templates/zodb/setup.cfg_tmpl @@ -0,0 +1,27 @@ +[nosetests] +match=^test +nocapture=1 +cover-package={{package}} +with-coverage=1 +cover-erase=1 + +[compile_catalog] +directory = {{package}}/locale +domain = {{project}} +statistics = true + +[extract_messages] +add_comments = TRANSLATORS: +output_file = {{package}}/locale/{{project}}.pot +width = 80 + +[init_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale + +[update_catalog] +domain = {{project}} +input_file = {{package}}/locale/{{project}}.pot +output_dir = {{package}}/locale +previous = true diff --git a/repoze/bfg/settings.py b/repoze/bfg/settings.py index 069d25e6c..9cb2502a1 100644 --- a/repoze/bfg/settings.py +++ b/repoze/bfg/settings.py @@ -36,12 +36,16 @@ class Settings(dict): config_reload_resources)) configure_zcml = self.get('configure_zcml', '') eff_configure_zcml = eget('BFG_CONFIGURE_ZCML', configure_zcml) + locale_name = self.get('locale_name', 'en') + eff_locale_name = eget('BFG_DEFAULT_LOCALE_NAME', locale_name) + update = { 'debug_authorization': eff_debug_all or eff_debug_auth, 'debug_notfound': eff_debug_all or eff_debug_notfound, 'reload_templates': eff_reload_all or eff_reload_templates, 'reload_resources':eff_reload_all or eff_reload_resources, 'configure_zcml':eff_configure_zcml, + 'default_locale_name':eff_locale_name, } self.update(update) diff --git a/repoze/bfg/tests/fixtures/__init__.py b/repoze/bfg/tests/fixtures/__init__.py new file mode 100644 index 000000000..1a35cdb4a --- /dev/null +++ b/repoze/bfg/tests/fixtures/__init__.py @@ -0,0 +1 @@ +# a file diff --git a/repoze/bfg/tests/fixtures/locale/GARBAGE b/repoze/bfg/tests/fixtures/locale/GARBAGE new file mode 100644 index 000000000..032c55584 --- /dev/null +++ b/repoze/bfg/tests/fixtures/locale/GARBAGE @@ -0,0 +1 @@ +Garbage file. diff --git a/repoze/bfg/tests/fixtures/locale/be/LC_MESSAGES b/repoze/bfg/tests/fixtures/locale/be/LC_MESSAGES new file mode 100644 index 000000000..909cf6a3b --- /dev/null +++ b/repoze/bfg/tests/fixtures/locale/be/LC_MESSAGES @@ -0,0 +1 @@ +busted. diff --git a/repoze/bfg/tests/fixtures/locale/de/LC_MESSAGES/deformsite.mo b/repoze/bfg/tests/fixtures/locale/de/LC_MESSAGES/deformsite.mo Binary files differnew file mode 100644 index 000000000..2924a5eb5 --- /dev/null +++ b/repoze/bfg/tests/fixtures/locale/de/LC_MESSAGES/deformsite.mo diff --git a/repoze/bfg/tests/fixtures/locale/de/LC_MESSAGES/deformsite.po b/repoze/bfg/tests/fixtures/locale/de/LC_MESSAGES/deformsite.po new file mode 100644 index 000000000..17f87bc19 --- /dev/null +++ b/repoze/bfg/tests/fixtures/locale/de/LC_MESSAGES/deformsite.po @@ -0,0 +1,31 @@ +# German translations for deformsite. +# Copyright (C) 2010 ORGANIZATION +# This file is distributed under the same license as the deformsite project. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2010. +# +msgid "" +msgstr "" +"Project-Id-Version: deformsite 0.0\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2010-04-22 14:17+0400\n" +"PO-Revision-Date: 2010-04-22 14:17-0400\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: de <LL@li.org>\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 0.9.5\n" + +#: deformsite/__init__.py:458 +msgid "Approve" +msgstr "Genehmigen" + +#: deformsite/__init__.py:459 +msgid "Show approval" +msgstr "Zeigen Genehmigung" + +#: deformsite/__init__.py:466 +msgid "Submit" +msgstr "Beugen" + diff --git a/repoze/bfg/tests/fixtures/locale/en/LC_MESSAGES/deformsite.mo b/repoze/bfg/tests/fixtures/locale/en/LC_MESSAGES/deformsite.mo Binary files differnew file mode 100644 index 000000000..2924a5eb5 --- /dev/null +++ b/repoze/bfg/tests/fixtures/locale/en/LC_MESSAGES/deformsite.mo diff --git a/repoze/bfg/tests/fixtures/locale/en/LC_MESSAGES/deformsite.po b/repoze/bfg/tests/fixtures/locale/en/LC_MESSAGES/deformsite.po new file mode 100644 index 000000000..17f87bc19 --- /dev/null +++ b/repoze/bfg/tests/fixtures/locale/en/LC_MESSAGES/deformsite.po @@ -0,0 +1,31 @@ +# German translations for deformsite. +# Copyright (C) 2010 ORGANIZATION +# This file is distributed under the same license as the deformsite project. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2010. +# +msgid "" +msgstr "" +"Project-Id-Version: deformsite 0.0\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2010-04-22 14:17+0400\n" +"PO-Revision-Date: 2010-04-22 14:17-0400\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: de <LL@li.org>\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 0.9.5\n" + +#: deformsite/__init__.py:458 +msgid "Approve" +msgstr "Genehmigen" + +#: deformsite/__init__.py:459 +msgid "Show approval" +msgstr "Zeigen Genehmigung" + +#: deformsite/__init__.py:466 +msgid "Submit" +msgstr "Beugen" + diff --git a/repoze/bfg/tests/test_chameleon_text.py b/repoze/bfg/tests/test_chameleon_text.py index 007c102e3..204805adb 100644 --- a/repoze/bfg/tests/test_chameleon_text.py +++ b/repoze/bfg/tests/test_chameleon_text.py @@ -28,6 +28,16 @@ class Base: class TextTemplateRendererTests(Base, unittest.TestCase): + def setUp(self): + from repoze.bfg.configuration import Configurator + from repoze.bfg.registry import Registry + registry = Registry() + self.config = Configurator(registry=registry) + self.config.begin() + + def tearDown(self): + self.config.end() + def _getTargetClass(self): from repoze.bfg.chameleon_text import TextTemplateRenderer return TextTemplateRenderer @@ -54,6 +64,16 @@ class TextTemplateRendererTests(Base, unittest.TestCase): template = instance.template self.assertEqual(template, instance.__dict__['template']) + def test_template_with_ichameleon_translate(self): + from repoze.bfg.interfaces import IChameleonTranslate + def ct(): pass + self.config.registry.registerUtility(ct, IChameleonTranslate) + minimal = self._getTemplatePath('minimal.txt') + instance = self._makeOne(minimal) + self.failIf('template' in instance.__dict__) + template = instance.template + self.assertEqual(template.translate, ct) + def test_call(self): minimal = self._getTemplatePath('minimal.txt') instance = self._makeOne(minimal) diff --git a/repoze/bfg/tests/test_chameleon_zpt.py b/repoze/bfg/tests/test_chameleon_zpt.py index e4bf8f766..cbf9dd10b 100644 --- a/repoze/bfg/tests/test_chameleon_zpt.py +++ b/repoze/bfg/tests/test_chameleon_zpt.py @@ -21,6 +21,16 @@ class Base(object): return reg class ZPTTemplateRendererTests(Base, unittest.TestCase): + def setUp(self): + from repoze.bfg.configuration import Configurator + from repoze.bfg.registry import Registry + registry = Registry() + self.config = Configurator(registry=registry) + self.config.begin() + + def tearDown(self): + self.config.end() + def _getTargetClass(self): from repoze.bfg.chameleon_zpt import ZPTTemplateRenderer return ZPTTemplateRenderer @@ -55,6 +65,16 @@ class ZPTTemplateRendererTests(Base, unittest.TestCase): template = instance.template self.assertEqual(template, instance.__dict__['template']) + def test_template_with_ichameleon_translate(self): + from repoze.bfg.interfaces import IChameleonTranslate + def ct(): pass + self.config.registry.registerUtility(ct, IChameleonTranslate) + minimal = self._getTemplatePath('minimal.pt') + instance = self._makeOne(minimal) + self.failIf('template' in instance.__dict__) + template = instance.template + self.assertEqual(template.translate, ct) + def test_call_with_nondict_value(self): minimal = self._getTemplatePath('minimal.pt') instance = self._makeOne(minimal) diff --git a/repoze/bfg/tests/test_configuration.py b/repoze/bfg/tests/test_configuration.py index 99d564b91..a1e753179 100644 --- a/repoze/bfg/tests/test_configuration.py +++ b/repoze/bfg/tests/test_configuration.py @@ -255,6 +255,15 @@ class ConfiguratorTests(unittest.TestCase): config.setup_registry() self.failUnless(reg.getUtility(IRootFactory)) + def test_setup_registry_locale_negotiator(self): + from repoze.bfg.registry import Registry + from repoze.bfg.interfaces import ILocaleNegotiator + reg = Registry() + config = self._makeOne(reg) + config.setup_registry(locale_negotiator='abc') + utility = reg.getUtility(ILocaleNegotiator) + self.assertEqual(utility, 'abc') + def test_setup_registry_alternate_renderers(self): from repoze.bfg.registry import Registry from repoze.bfg.interfaces import IRendererFactory @@ -1797,6 +1806,54 @@ class ConfiguratorTests(unittest.TestCase): self.assertEqual( config.registry.getUtility(IAuthorizationPolicy), policy) + def test_set_locale_negotiator(self): + from repoze.bfg.interfaces import ILocaleNegotiator + config = self._makeOne() + def negotiator(request): pass + config.set_locale_negotiator(negotiator) + self.assertEqual(config.registry.getUtility(ILocaleNegotiator), + negotiator) + + def test_add_translation_dirs_missing_dir(self): + from repoze.bfg.exceptions import ConfigurationError + config = self._makeOne() + self.assertRaises(ConfigurationError, + config.add_translation_dirs, + '/wont/exist/on/my/system') + + def test_add_translation_dirs_resource_spec(self): + import os + from repoze.bfg.interfaces import ITranslationDirectories + config = self._makeOne() + config.add_translation_dirs('repoze.bfg.tests.fixtures:locale') + here = os.path.dirname(__file__) + locale = os.path.join(here, 'fixtures', 'locale') + self.assertEqual(config.registry.getUtility(ITranslationDirectories), + [locale]) + + def test_add_translation_dirs_registers_chameleon_translate(self): + from repoze.bfg.interfaces import IChameleonTranslate + from repoze.bfg.threadlocal import manager + request = DummyRequest() + config = self._makeOne() + manager.push({'request':request, 'registry':config.registry}) + try: + config.add_translation_dirs('repoze.bfg.tests.fixtures:locale') + translate = config.registry.getUtility(IChameleonTranslate) + self.assertEqual(translate('Approve'), u'Approve') + finally: + manager.pop() + + def test_add_translation_dirs_abspath(self): + import os + from repoze.bfg.interfaces import ITranslationDirectories + config = self._makeOne() + here = os.path.dirname(__file__) + locale = os.path.join(here, 'fixtures', 'locale') + config.add_translation_dirs(locale) + self.assertEqual(config.registry.getUtility(ITranslationDirectories), + [locale]) + def test__renderer_from_name_default_renderer(self): from repoze.bfg.interfaces import IRendererFactory config = self._makeOne() diff --git a/repoze/bfg/tests/test_i18n.py b/repoze/bfg/tests/test_i18n.py new file mode 100644 index 000000000..9c4b226e4 --- /dev/null +++ b/repoze/bfg/tests/test_i18n.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +# + +import unittest +from repoze.bfg.testing import cleanUp + +class TestTranslationString(unittest.TestCase): + def _makeOne(self, *arg, **kw): + from repoze.bfg.i18n import TranslationString + return TranslationString(*arg, **kw) + + def test_it(self): + # this is part of the API, we don't actually need to test much more + # than that it's importable + ts = self._makeOne('a') + self.assertEqual(ts, 'a') + +class TestTranslationStringFactory(unittest.TestCase): + def _makeOne(self, *arg, **kw): + from repoze.bfg.i18n import TranslationStringFactory + return TranslationStringFactory(*arg, **kw) + + def test_it(self): + # this is part of the API, we don't actually need to test much more + # than that it's importable + factory = self._makeOne('a') + self.assertEqual(factory('').domain, 'a') + +class TestLocalizer(unittest.TestCase): + def _makeOne(self, *arg, **kw): + from repoze.bfg.i18n import Localizer + return Localizer(*arg, **kw) + + def test_ctor(self): + localizer = self._makeOne('en_US', None) + self.assertEqual(localizer.locale_name, 'en_US') + self.assertEqual(localizer.translations, None) + + def test_translate(self): + translations = DummyTranslations() + localizer = self._makeOne(None, translations) + self.assertEqual(localizer.translate('123', domain='1', + mapping={}), '123') + self.failUnless(localizer.translator) + + def test_pluralize(self): + translations = DummyTranslations() + localizer = self._makeOne(None, translations) + self.assertEqual(localizer.pluralize('singular', 'plural', 1, + domain='1', mapping={}), + 'singular') + self.failUnless(localizer.pluralizer) + +class Test_negotiate_locale_name(unittest.TestCase): + def setUp(self): + cleanUp() + + def tearDown(self): + cleanUp() + + def _callFUT(self, request): + from repoze.bfg.i18n import negotiate_locale_name + return negotiate_locale_name(request) + + def _registerImpl(self, impl): + from repoze.bfg.threadlocal import get_current_registry + registry = get_current_registry() + from repoze.bfg.interfaces import ILocaleNegotiator + registry.registerUtility(impl, ILocaleNegotiator) + + def test_no_registry_on_request(self): + self._registerImpl(dummy_negotiator) + request = DummyRequest() + result = self._callFUT(request) + self.assertEqual(result, 'bogus') + + def test_with_registry_on_request(self): + from repoze.bfg.threadlocal import get_current_registry + registry = get_current_registry() + self._registerImpl(dummy_negotiator) + request = DummyRequest() + request.registry = registry + result = self._callFUT(request) + self.assertEqual(result, 'bogus') + + def test_default_from_settings(self): + from repoze.bfg.threadlocal import get_current_registry + registry = get_current_registry() + settings = {'default_locale_name':'settings'} + from repoze.bfg.interfaces import ISettings + registry.registerUtility(settings, ISettings) + request = DummyRequest() + request.registry = registry + result = self._callFUT(request) + self.assertEqual(result, 'settings') + + def test_default_default(self): + request = DummyRequest() + result = self._callFUT(request) + self.assertEqual(result, 'en') + +class Test_get_locale_name(unittest.TestCase): + def setUp(self): + cleanUp() + + def tearDown(self): + cleanUp() + + def _callFUT(self, request): + from repoze.bfg.i18n import get_locale_name + return get_locale_name(request) + + def _registerImpl(self, impl): + from repoze.bfg.threadlocal import get_current_registry + registry = get_current_registry() + from repoze.bfg.interfaces import ILocaleNegotiator + registry.registerUtility(impl, ILocaleNegotiator) + + def test_name_on_request(self): + request = DummyRequest() + request.bfg_locale_name = 'ie' + result = self._callFUT(request) + self.assertEqual(result, 'ie') + + def test_name_not_on_request(self): + self._registerImpl(dummy_negotiator) + request = DummyRequest() + result = self._callFUT(request) + self.assertEqual(result, 'bogus') + self.assertEqual(request.bfg_locale_name, 'bogus') + +class Test_get_localizer(unittest.TestCase): + def setUp(self): + cleanUp() + + def tearDown(self): + cleanUp() + + def _callFUT(self, request): + from repoze.bfg.i18n import get_localizer + return get_localizer(request) + + def test_no_registry_on_request(self): + request = DummyRequest() + request.bfg_localizer = '123' + result = self._callFUT(request) + self.assertEqual(result, '123') + + def test_with_registry_on_request(self): + from repoze.bfg.threadlocal import get_current_registry + registry = get_current_registry() + request = DummyRequest() + request.bfg_localizer = '123' + request.registry = registry + result = self._callFUT(request) + self.assertEqual(result, '123') + + def test_locale_on_request(self): + request = DummyRequest() + request.bfg_localizer = 'abc' + result = self._callFUT(request) + self.assertEqual(result, 'abc') + + def test_locale_from_registry(self): + from repoze.bfg.threadlocal import get_current_registry + from repoze.bfg.interfaces import ILocalizer + registry = get_current_registry() + locale = 'abc' + registry.registerUtility(locale, ILocalizer, name='en') + request = DummyRequest() + request.bfg_locale_name = 'en' + result = self._callFUT(request) + self.assertEqual(result, 'abc') + + def test_locale_from_mo(self): + import os + from repoze.bfg.threadlocal import get_current_registry + from repoze.bfg.interfaces import ITranslationDirectories + from repoze.bfg.i18n import Localizer + registry = get_current_registry() + here = os.path.dirname(__file__) + localedir = os.path.join(here, 'fixtures', 'locale') + localedirs = [localedir] + registry.registerUtility(localedirs, ITranslationDirectories) + request = DummyRequest() + request.bfg_locale_name = 'de' + result = self._callFUT(request) + self.assertEqual(result.__class__, Localizer) + self.assertEqual(result.translate('Approve', 'deformsite'), + 'Genehmigen') + self.assertEqual(result.translate('Approve'), 'Approve') + self.failUnless(hasattr(result, 'pluralize')) + + def test_locale_from_mo_bad_mo(self): + import os + from repoze.bfg.threadlocal import get_current_registry + from repoze.bfg.interfaces import ITranslationDirectories + from repoze.bfg.i18n import Localizer + registry = get_current_registry() + here = os.path.dirname(__file__) + localedir = os.path.join(here, 'fixtures', 'locale') + localedirs = [localedir] + registry.registerUtility(localedirs, ITranslationDirectories) + request = DummyRequest() + request.bfg_locale_name = 'be' + result = self._callFUT(request) + self.assertEqual(result.__class__, Localizer) + self.assertEqual(result.translate('Approve', 'deformsite'), + 'Approve') + +class Test_default_locale_negotiator(unittest.TestCase): + def setUp(self): + cleanUp() + + def tearDown(self): + cleanUp() + + def _callFUT(self, request): + from repoze.bfg.i18n import default_locale_negotiator + return default_locale_negotiator(request) + + def test_from_settings(self): + from repoze.bfg.interfaces import ISettings + from repoze.bfg.threadlocal import get_current_registry + settings = {'default_locale_name':'dude'} + registry = get_current_registry() + registry.registerUtility(settings, ISettings) + request = DummyRequest() + result = self._callFUT(request) + self.assertEqual(result, 'dude') + + def test_settings_empty(self): + request = DummyRequest() + result = self._callFUT(request) + self.assertEqual(result, 'en') + + def test_from_params(self): + request = DummyRequest() + request.params['locale'] = 'foo' + result = self._callFUT(request) + self.assertEqual(result, 'foo') + +class TestTranslations(unittest.TestCase): + def _getTargetClass(self): + from repoze.bfg.i18n import Translations + return Translations + + def _makeOne(self): + messages1 = [ + ('foo', 'Voh'), + (('foo1', 1), 'Voh1'), + ] + messages2 = [ + ('foo', 'VohD'), + (('foo1', 1), 'VohD1'), + ] + + klass = self._getTargetClass() + + translations1 = klass(None, domain='messages') + translations1._catalog = dict(messages1) + translations1.plural = lambda *arg: 1 + translations2 = klass(None, domain='messages1') + translations2._catalog = dict(messages2) + translations2.plural = lambda *arg: 1 + translations = translations1.add(translations2, merge=False) + return translations + + def test_load_domain_None(self): + import gettext + import os + here = os.path.dirname(__file__) + localedir = os.path.join(here, 'fixtures', 'locale') + locales = ['de', 'en'] + klass = self._getTargetClass() + result = klass.load(localedir, locales, domain=None) + self.assertEqual(result.__class__, gettext.NullTranslations) + + def test_load_found_locale_and_domain(self): + import os + here = os.path.dirname(__file__) + localedir = os.path.join(here, 'fixtures', 'locale') + locales = ['de', 'en'] + klass = self._getTargetClass() + result = klass.load(localedir, locales, domain='deformsite') + self.assertEqual(result.__class__, klass) + + def test_load_found_locale_and_domain_locale_is_string(self): + import os + here = os.path.dirname(__file__) + localedir = os.path.join(here, 'fixtures', 'locale') + locales = 'de' + klass = self._getTargetClass() + result = klass.load(localedir, locales, domain='deformsite') + self.assertEqual(result.__class__, klass) + + def test___repr__(self): + inst = self._makeOne() + result = repr(inst) + self.assertEqual(result, '<Translations: "None">') + + def test_merge_not_gnutranslations(self): + inst = self._makeOne() + self.assertEqual(inst.merge(None), inst) + + def test_merge_gnutranslations(self): + inst = self._makeOne() + inst2 = self._makeOne() + inst2._catalog['a'] = 'b' + inst.merge(inst2) + self.assertEqual(inst._catalog['a'], 'b') + + def test_add_different_domain_merge_true_notexisting(self): + inst = self._makeOne() + inst2 = self._makeOne() + inst2.domain = 'domain2' + inst.add(inst2) + self.assertEqual(inst._domains['domain2'], inst2) + + def test_add_different_domain_merge_true_existing(self): + inst = self._makeOne() + inst2 = self._makeOne() + inst3 = self._makeOne() + inst2.domain = 'domain2' + inst2._catalog['a'] = 'b' + inst3.domain = 'domain2' + inst._domains['domain2'] = inst3 + inst.add(inst2) + self.assertEqual(inst._domains['domain2'], inst3) + self.assertEqual(inst3._catalog['a'], 'b') + + def test_add_same_domain_merge_true(self): + inst = self._makeOne() + inst2 = self._makeOne() + inst2._catalog['a'] = 'b' + inst.add(inst2) + self.assertEqual(inst._catalog['a'], 'b') + + def test_dgettext(self): + t = self._makeOne() + self.assertEqual(t.dgettext('messages', 'foo'), 'Voh') + self.assertEqual(t.dgettext('messages1', 'foo'), 'VohD') + + def test_ldgettext(self): + t = self._makeOne() + self.assertEqual(t.ldgettext('messages', 'foo'), 'Voh') + self.assertEqual(t.ldgettext('messages1', 'foo'), 'VohD') + + def test_dugettext(self): + t = self._makeOne() + self.assertEqual(t.dugettext('messages', 'foo'), 'Voh') + self.assertEqual(t.dugettext('messages1', 'foo'), 'VohD') + + def test_dngettext(self): + t = self._makeOne() + self.assertEqual(t.dngettext('messages', 'foo1', 'foos1', 1), 'Voh1') + self.assertEqual(t.dngettext('messages1', 'foo1', 'foos1', 1), 'VohD1') + + def test_ldngettext(self): + t = self._makeOne() + self.assertEqual(t.ldngettext('messages', 'foo1', 'foos1', 1), 'Voh1') + self.assertEqual(t.ldngettext('messages1', 'foo1', 'foos1', 1), 'VohD1') + + def test_dungettext(self): + t = self._makeOne() + self.assertEqual(t.dungettext('messages', 'foo1', 'foos1', 1), 'Voh1') + self.assertEqual(t.dungettext('messages1', 'foo1', 'foos1', 1), 'VohD1') + + +class DummyRequest(object): + def __init__(self): + self.params = {} + +def dummy_negotiator(request): + return 'bogus' + +class DummyTranslations(object): + def ugettext(self, text): + return text + + def ungettext(self, singular, plural, n): + return singular diff --git a/repoze/bfg/tests/test_zcml.py b/repoze/bfg/tests/test_zcml.py index 7f42e9f09..5fdf25f33 100644 --- a/repoze/bfg/tests/test_zcml.py +++ b/repoze/bfg/tests/test_zcml.py @@ -1041,6 +1041,56 @@ class TestUtilityDirective(unittest.TestCase): self.assertEqual(utility['args'], (component, IFactory, '', None)) self.assertEqual(utility['kw'], {}) +class TestTranslationDirDirective(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + + def _callFUT(self, *arg, **kw): + from repoze.bfg.zcml import translationdir + return translationdir(*arg, **kw) + + def test_it(self): + from repoze.bfg.configuration import Configurator + context = DummyContext() + tdir = 'repoze.bfg.tests.fixtures:locale' + self._callFUT(context, tdir) + actions = context.actions + self.assertEqual(len(actions), 1) + action = context.actions[0] + self.assertEqual(action['discriminator'], ('tdir', tdir)) + self.assertEqual(action['callable'].im_func, + Configurator.add_translation_dirs.im_func) + self.assertEqual(action['args'], (tdir,)) + action['callable'](*action['args']) # doesn't blow up + +class TestLocaleNegotiatorDirective(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + + def _callFUT(self, *arg, **kw): + from repoze.bfg.zcml import localenegotiator + return localenegotiator(*arg, **kw) + + def test_it(self): + from repoze.bfg.configuration import Configurator + context = DummyContext() + dummy_negotiator = object() + self._callFUT(context, dummy_negotiator) + actions = context.actions + self.assertEqual(len(actions), 1) + action = context.actions[0] + self.assertEqual(action['discriminator'], 'lnegotiator') + self.assertEqual(action['callable'].im_func, + Configurator.set_locale_negotiator.im_func) + self.assertEqual(action['args'], (dummy_negotiator,)) + action['callable'](*action['args']) # doesn't blow up + class TestLoadZCML(unittest.TestCase): def setUp(self): testing.setUp() diff --git a/repoze/bfg/zcml.py b/repoze/bfg/zcml.py index 7ba82bfe7..3e8bb0c50 100644 --- a/repoze/bfg/zcml.py +++ b/repoze/bfg/zcml.py @@ -585,6 +585,41 @@ def scan(_context, package): args=(package, None, _context.info) ) +class ITranslationDirDirective(Interface): + dir = TextLine( + title=u"Add a translation directory", + description=(u"Add a translation directory"), + required=True, + ) + +def translationdir(_context, dir): + path = path_spec(_context, dir) + reg = get_current_registry() + config = Configurator(reg, package=_context.package) + + _context.action( + discriminator = ('tdir', path), + callable=config.add_translation_dirs, + args = (dir,), + ) + +class ILocaleNegotiatorDirective(Interface): + negotiator = GlobalObject( + title=u"Configure a locale negotiator", + description=(u'Configure a locale negotiator'), + required=True, + ) + +def localenegotiator(_context, negotiator): + reg = get_current_registry() + config = Configurator(reg, package=_context.package) + + _context.action( + discriminator = 'lnegotiator', + callable=config.set_locale_negotiator, + args = (negotiator,) + ) + class IAdapterDirective(Interface): """ Register an adapter @@ -27,7 +27,7 @@ except IOError: README = CHANGES = '' install_requires=[ - 'Chameleon', + 'Chameleon >= 1.2.3', 'Paste > 1.7', # temp version pin to prevent PyPi install failure :-( 'PasteDeploy', 'PasteScript', @@ -39,6 +39,7 @@ install_requires=[ 'zope.deprecation', 'zope.interface >= 3.5.1', # 3.5.0 comment: "allow to bootstrap on jython" 'venusian >= 0.2', + 'translationstring', ] if sys.version_info[:2] < (2, 6): |
