Repository: Ouranosinc/xclim

Issue:
Internationalization of metadata
A need for internal Ouranosinc projects, but potentially for external users, is to have available metadata in another language then english. As far as I know, there is not CF-standardized way of adding multilingual metadata in a netCDF, so we're our own for this.
Also, while python has the GNU `gettext` tool built-in, it might not be what we want. But, I have found no examples of other python packages similar to xclim implementing such a feature. So on our own here to.

In this thread, I'd like to have people's input on the way to go forward. I have narrowed it down to 3 main options + ancillary questions :

Internationalization support in `atmos`, `seaIce` and `land`

1) None. This is out-of-scope for `xclim`
2) Gettext-based translation. We could ship pre-compiled gettext files with xclim and use python's gettext to generate the metadata. We could use tools like [`babel`](http://babel.pocoo.org/en/latest/) to help.
3) Json-based translation. As we have a limited number and explicit list of text fields to translate, we could json files to xclim, for each locale, mapping indice names and fields to translated strings. Would need are own tools to generate the fields.

How to (for 2 and 3):

A) Monkey-patching. If loading xclim and calling `xclim.add_locale('fr')`, all indices would be monkey-patched so that they contain the translated fields too.
B) `set_locale` + context. The `Indicator` class would be modified so it adds the translations from the globally- or context-set `locale` option.  Similar to xarray's `set_options`.

Metadata:
I suggest adding "_fr" and similar to the localized attributes.

My favorite way is 3B.  What do you think?

Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 9915f137..28014490 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -4,6 +4,7 @@ History
 
 0.15.x
 ------
+* Internationalization of xclim : add `locales` submodule for localized metadata.
 * Add feature to retrieve coordinate values instead of index in `run_length.first_run`. Add `run_length.last_run`.
 * Fix bug in subset_gridpoint to work on lat/lon coords of any dimension when they are not a dimension of the data.
 
diff --git a/docs/conf.py b/docs/conf.py
index 24b61f27..6ee06126 100755
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -32,7 +32,7 @@ def _get_indicators(module):
     modules : sequence
       Sequence of modules to inspect.
     """
-    import xclim.utils as xcu
+    import xclim.indicator as xcu
 
     out = {}
     for key, val in module.__dict__.items():
diff --git a/xclim/atmos/_precip.py b/xclim/atmos/_precip.py
index 7d595586..2e16a363 100644
--- a/xclim/atmos/_precip.py
+++ b/xclim/atmos/_precip.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from xclim import indices
-from xclim.utils import Indicator
-from xclim.utils import Indicator2D
+from xclim.indicator import Indicator
+from xclim.indicator import Indicator2D
 from xclim.utils import wrapped_partial
 
 __all__ = [
diff --git a/xclim/atmos/_temperature.py b/xclim/atmos/_temperature.py
index 7b401c7d..384b32d3 100644
--- a/xclim/atmos/_temperature.py
+++ b/xclim/atmos/_temperature.py
@@ -3,8 +3,8 @@ import abc
 
 from xclim import checks
 from xclim import indices
-from xclim.utils import Indicator
-from xclim.utils import Indicator2D
+from xclim.indicator import Indicator
+from xclim.indicator import Indicator2D
 
 __all__ = [
     "tn_days_below",
diff --git a/xclim/indicator.py b/xclim/indicator.py
new file mode 100644
index 00000000..f603b597
--- /dev/null
+++ b/xclim/indicator.py
@@ -0,0 +1,339 @@
+# -*- coding: utf-8 -*-
+"""
+xclim indicator module
+"""
+import datetime as dt
+import re
+import warnings
+from collections import defaultdict
+from collections import OrderedDict
+from inspect import signature
+from typing import Sequence
+from typing import Union
+
+import numpy as np
+from boltons.funcutils import wraps
+
+import xclim
+from xclim import checks
+from xclim.locales import get_local_attrs
+from xclim.locales import get_local_formatter
+from xclim.locales import LOCALES
+from xclim.utils import AttrFormatter
+from xclim.utils import convert_units_to
+from xclim.utils import default_formatter
+from xclim.utils import parse_doc
+from xclim.utils import units
+
+
+# This class needs to be subclassed by individual indicator classes defining metadata information, compute and
+# missing functions. It can handle indicators with any number of forcing fields.
+class Indicator:
+    r"""Climate indicator based on xarray
+    """
+    # Unique ID for function registry.
+    identifier = ""
+
+    # Output variable name. May use tags {<tag>} that will be formatted at runtime.
+    var_name = ""
+
+    _nvar = 1
+
+    # CF-Convention metadata to be attributed to the output variable. May use tags {<tag>} formatted at runtime.
+    # The set of permissible standard names is contained in the standard name table.
+    standard_name = ""
+    long_name = ""  # Parsed.
+    units = ""  # Representative units of the physical quantity.
+    cell_methods = ""  # List of blank-separated words of the form "name: method"
+    description = ""  # The description is meant to clarify the qualifiers of the fundamental quantities, such as which
+    #   surface a quantity is defined on or what the flux sign conventions are.
+
+    # The `pint` unit context. Use 'hydro' to allow conversion from kg m-2 s-1 to mm/day.
+    context = "none"
+
+    # Additional information that can be used by third party libraries or to describe the file content.
+    title = ""  # A succinct description of what is in the dataset. Default parsed from compute.__doc__
+    abstract = ""  # Parsed
+    keywords = ""  # Comma separated list of keywords
+    # Published or web-based references that describe the data or methods used to produce it. Parsed.
+    references = ""
+    comment = (
+        ""  # Miscellaneous information about the data or methods used to produce it.
+    )
+    notes = ""  # Mathematical formulation. Parsed.
+
+    # Allowed metadata attributes on the output
+    _cf_names = [
+        "standard_name",
+        "long_name",
+        "units",
+        "cell_methods",
+        "description",
+        "comment",
+        "references",
+    ]
+
+    # metadata fields that are formatted as free text.
+    _text_fields = ["long_name", "description", "comment"]
+
+    # Whether or not the compute function is a partial.
+    _partial = False
+
+    # Can be used to override the compute docstring.
+    doc_template = None
+
+    def __init__(self, **kwds):
+
+        # Set instance attributes.
+        for key, val in kwds.items():
+            setattr(self, key, val)
+
+        # Verify that the identifier is a proper slug
+        if not re.match(r"^[-\w]+$", self.identifier):
+            warnings.warn(
+                "The identifier contains non-alphanumeric characters. It could make life "
+                "difficult for downstream software reusing this class.",
+                UserWarning,
+            )
+
+        # Default value for `var_name` is the `identifier`.
+        if self.var_name == "":
+            self.var_name = self.identifier
+
+        # Extract information from the `compute` function.
+        # The signature
+        self._sig = signature(self.compute)
+
+        # The input parameter names
+        self._parameters = tuple(self._sig.parameters.keys())
+        #        self._input_params = [p for p in self._sig.parameters.values() if p.default is p.empty]
+        #        self._nvar = len(self._input_params)
+
+        # Copy the docstring and signature
+        self.__call__ = wraps(self.compute)(self.__call__.__func__)
+        if self.doc_template is not None:
+            self.__call__.__doc__ = self.doc_template.format(i=self)
+
+        # Fill in missing metadata from the doc
+        meta = parse_doc(self.compute.__doc__)
+        for key in ["abstract", "title", "notes", "references"]:
+            setattr(self, key, getattr(self, key) or meta.get(key, ""))
+
+    def __call__(self, *args, **kwds):
+        # Bind call arguments. We need to use the class signature, not the instance, otherwise it removes the first
+        # argument.
+        if self._partial:
+            ba = self._sig.bind_partial(*args, **kwds)
+            for key, val in self.compute.keywords.items():
+                if key not in ba.arguments:
+                    ba.arguments[key] = val
+        else:
+            ba = self._sig.bind(*args, **kwds)
+            ba.apply_defaults()
+
+        # Get history and cell method attributes from source data
+        attrs = defaultdict(str)
+        for i in range(self._nvar):
+            p = self._parameters[i]
+            for attr in ["history", "cell_methods"]:
+                attrs[attr] += f"{p}: " if self._nvar > 1 else ""
+                attrs[attr] += getattr(ba.arguments[p], attr, "")
+                if attrs[attr]:
+                    attrs[attr] += "\n" if attr == "history" else " "
+
+        # Update attributes
+        out_attrs = self.format(self.cf_attrs, ba.arguments)
+        for locale in LOCALES:
+            out_attrs.update(
+                self.format(
+                    get_local_attrs(
+                        self,
+                        locale,
+                        names=self._cf_names,
+                        fill_missing=False,
+                        append_locale_name=True,
+                    ),
+                    args=ba.arguments,
+                    formatter=get_local_formatter(locale),
+                )
+            )
+        vname = self.format({"var_name": self.var_name}, ba.arguments)["var_name"]
+
+        # Update the signature with the values of the actual call.
+        cp = OrderedDict()
+        for (k, v) in ba.signature.parameters.items():
+            if v.default is not None and isinstance(v.default, (float, int, str)):
+                cp[k] = v.replace(default=ba.arguments[k])
+            else:
+                cp[k] = v
+
+        attrs[
+            "history"
+        ] += "[{:%Y-%m-%d %H:%M:%S}] {}: {}{} - xclim version: {}.".format(
+            dt.datetime.now(),
+            vname,
+            self.identifier,
+            ba.signature.replace(parameters=cp.values()),
+            xclim.__version__,
+        )
+        attrs["cell_methods"] += out_attrs.pop("cell_methods", "")
+        attrs.update(out_attrs)
+
+        # Assume the first arguments are always the DataArray.
+        das = tuple(ba.arguments.pop(self._parameters[i]) for i in range(self._nvar))
+
+        # Pre-computation validation checks
+        for da in das:
+            self.validate(da)
+        self.cfprobe(*das)
+
+        # Compute the indicator values, ignoring NaNs.
+        out = self.compute(*das, **ba.kwargs)
+
+        # Convert to output units
+        out = convert_units_to(out, self.units, self.context)
+
+        # Update netCDF attributes
+        out.attrs.update(attrs)
+
+        # Bind call arguments to the `missing` function, whose signature might be different from `compute`.
+        mba = signature(self.missing).bind(*das, **ba.arguments)
+
+        # Mask results that do not meet criteria defined by the `missing` method.
+        mask = self.missing(*mba.args, **mba.kwargs)
+        ma_out = out.where(~mask)
+
+        return ma_out.rename(vname)
+
+    def translate_attrs(
+        self, locale: Union[str, Sequence[str]], fill_missing: bool = True
+    ):
+        """Return a dictionary of unformated translated translatable attributes.
+
+        Translatable attributes are defined in xclim.locales.TRANSLATABLE_ATTRS
+
+        Parameters
+        ----------
+        locale : Union[str, Sequence[str]]
+            The POSIX name of the locale or a tuple of a locale name and a path to a
+            json file defining the translations. See `xclim.locale` for details.
+        fill_missing : bool
+            If True (default fill the missing attributes by their english values.
+        """
+        return get_local_attrs(
+            self, locale, fill_missing=fill_missing, append_locale_name=False
+        )
+
+    @property
+    def cf_attrs(self):
+        """CF-Convention attributes of the output value."""
+        attrs = {k: getattr(self, k) for k in self._cf_names if getattr(self, k)}
+        return attrs
+
+    def json(self, args=None):
+        """Return a dictionary representation of the class.
+
+        Notes
+        -----
+        This is meant to be used by a third-party library wanting to wrap this class into another interface.
+
+        """
+        names = ["identifier", "var_name", "abstract", "keywords"]
+        out = {key: getattr(self, key) for key in names}
+        out.update(self.cf_attrs)
+        out = self.format(out, args)
+
+        out["notes"] = self.notes
+
+        out["parameters"] = str(
+            {
+                key: {
+                    "default": p.default if p.default != p.empty else None,
+                    "desc": "",
+                }
+                for (key, p) in self._sig.parameters.items()
+            }
+        )
+
+        # if six.PY2:
+        #     out = walk_map(out, lambda x: x.decode('utf8') if isinstance(x, six.string_types) else x)
+
+        return out
+
+    def cfprobe(self, *das):
+        """Check input data compliance to expectations.
+        Warn of potential issues."""
+        return True
+
+    def compute(*args, **kwds):
+        """The function computing the indicator."""
+        raise NotImplementedError
+
+    def format(
+        self,
+        attrs: dict,
+        args: dict = None,
+        formatter: AttrFormatter = default_formatter,
+    ):
+        """Format attributes including {} tags with arguments.
+
+        Parameters
+        ----------
+        attrs: dict
+          Attributes containing tags to replace with arguments' values.
+        args : dict
+          Function call arguments.
+        """
+        if args is None:
+            return attrs
+
+        out = {}
+        for key, val in attrs.items():
+            mba = {"indexer": "annual"}
+            # Add formatting {} around values to be able to replace them with _attrs_mapping using format.
+            for k, v in args.items():
+                if isinstance(v, dict):
+                    if v:
+                        dk, dv = v.copy().popitem()
+                        if dk == "month":
+                            dv = "m{}".format(dv)
+                        mba[k] = dv
+                elif isinstance(v, units.Quantity):
+                    mba[k] = "{:g~P}".format(v)
+                elif isinstance(v, (int, float)):
+                    mba[k] = "{:g}".format(v)
+                else:
+                    mba[k] = v
+
+            if callable(val):
+                val = val(**mba)
+
+            out[key] = formatter.format(val, **mba)
+
+            if key in self._text_fields:
+                out[key] = out[key].strip().capitalize()
+
+        return out
+
+    @staticmethod
+    def missing(*args, **kwds):
+        """Return whether an output is considered missing or not."""
+        from functools import reduce
+
+        freq = kwds.get("freq")
+        if freq is not None:
+            # We flag any period with missing data
+            miss = (checks.missing_any(da, freq) for da in args)
+        else:
+            # There is no resampling, we flag where one of the input is missing
+            miss = (da.isnull() for da in args)
+        return reduce(np.logical_or, miss)
+
+    def validate(self, da):
+        """Validate input data requirements.
+        Raise error if conditions are not met."""
+        checks.assert_daily(da)
+
+
+class Indicator2D(Indicator):
+    _nvar = 2
diff --git a/xclim/land/_streamflow.py b/xclim/land/_streamflow.py
index f09b2d2b..a7227d8c 100644
--- a/xclim/land/_streamflow.py
+++ b/xclim/land/_streamflow.py
@@ -3,8 +3,8 @@ import numpy as np
 
 from xclim import checks
 from xclim import generic
+from xclim.indicator import Indicator
 from xclim.indices import base_flow_index
-from xclim.utils import Indicator
 from xclim.utils import wrapped_partial
 
 # from boltons.funcutils import FunctionBuilder
diff --git a/xclim/locales/__init__.py b/xclim/locales/__init__.py
new file mode 100644
index 00000000..af45cb6c
--- /dev/null
+++ b/xclim/locales/__init__.py
@@ -0,0 +1,336 @@
+# -*- coding: utf-8 -*-
+"""xclim Internationalization module
+
+Defines methods and object to help the internationalization of metadata for the
+climate indicators computed by xclim.
+
+All the methods and objects in this module use localization data given in json files.
+These files are expected to be defined as in this example for french:
+
+```json
+{
+    "attrs_mapping" : {
+        "modifiers": ["", "_f", "_mpl", "_fpl"],
+        "YS" : ["annuel", "annuelle", "annuels", "annuelles"],
+        ... and so on for other frequent parameters translation...
+    },
+    "atmos.dtrvar" : {
+        "long_name": "Variabilité de l'intervalle de température moyen",
+        "description": "Variabilité {freq_f} de l'intervalle de température moyen."
+    },
+    ... and so on for other indicators...
+}
+```
+
+Indicators are named by their module and identifier, which can differ from the callable name.
+In this case, the indicator is called through `atmos.daily_temperature_range_variability`,
+but its identifier is `dtrvar`.
+
+Here, the usual parameter passed to the formatting of "description" is "freq" and is usually
+translated from "YS" to "annual". However, in french and in this sentence, the feminine
+form should be used, so the "_f" modifier is added by the translator so that the
+formatting function knows which translation to use. Acceptable entries for the mappings
+are limited to what is already defined in `xclim.utils.Indicator._attrs_mapping`.
+
+The "attrs_mapping" and its key "modifiers" are mandatory in the locale dict, all other
+entries (translations of frequent parameters and all indicator entries) are optional.
+
+Attributes
+----------
+LOCALES : list
+    List of currently set locales. Computing indicator through a xclim.utils.Indicator
+    object will add metadata in these languages as available.
+TRANSLATABLE_ATTRS : list
+    List of attributes to consider translatable when generating locale dictionaries.
+"""
+import json
+import warnings
+from pathlib import Path
+from typing import Any
+from typing import Optional
+from typing import Sequence
+from typing import Tuple
+from typing import Union
+
+import pkg_resources
+
+from xclim.utils import AttrFormatter
+
+LOCALES = []
+TRANSLATABLE_ATTRS = ["long_name", "description", "comment", "title", "abstract"]
+
+
+def list_locales():
+    """Return a list of available locales in xclim."""
+    locale_list = pkg_resources.resource_listdir(__package__, "")
+    return [locale.split(".")[0] for locale in locale_list if locale.endswith(".json")]
+
+
+def get_best_locale(locale: str):
+    """Get the best fitting available locale.
+
+    for existing locales : ['fr', 'fr-BE', 'en-US'],
+    'fr-CA' returns 'fr', 'en' -> 'en-US' and 'en-GB' -> 'en-US'.
+
+    Parameters
+    ----------
+    locale : str
+        The requested locale, as an IETF language tag (lang or lang-territory)
+
+    Returns
+    -------
+    str or None:
+        The best available locale. None is none are available.
+    """
+    available = list_locales()
+    if locale in available:
+        return locale
+    locale = locale.split("-")[0]
+    if locale in available:
+        return locale
+    if locale in [av.split("-")[0] for av in available]:
+        return [av for av in available if av.split("-")[0] == locale][0]
+    return None
+
+
+def get_local_dict(locale: Union[str, Sequence[str], Tuple[str, dict]]):
+    """Return all translated metadata for a given locale.
+
+    Parameters
+    ----------
+    locale : str or sequence of str
+        IETF language tag or a tuple of the language tag and a translation dict, or
+        a tuple of the language tag and a path to a json file defining translation
+        of attributes.
+
+    Raises
+    ------
+    UnavailableLocaleError
+        If the given locale is not available.
+
+    Returns
+    -------
+    str
+        The best fitting locale string
+    dict
+        The available translations in this locale.
+    """
+    if isinstance(locale, str):
+        locale = get_best_locale(locale)
+        if locale is None:
+            raise UnavailableLocaleError(locale)
+
+        return (
+            locale,
+            json.load(pkg_resources.resource_stream(__package__, f"{locale}.json")),
+        )
+    if isinstance(locale[1], dict):
+        return locale
+    with open(locale[1]) as locf:
+        return locale[0], json.load(locf)
+
+
+def get_local_attrs(
+    indicator: Any,
+    *locales: Union[str, Sequence[str], Tuple[str, dict]],
+    names: Optional[Sequence[str]] = None,
+    fill_missing: bool = True,
+    append_locale_name: bool = True,
+):
+    """Get all attributes of an indicator in the requested locales.
+
+    Parameters
+    ----------
+    indicator : Union[utils.Indicator, utils.Indicator2D]
+        Indicator object
+    *locales : str
+        IETF language tag or a tuple of the language tag and a translation dict, or
+        a tuple of the language tag and a path to a json file defining translation
+        of attributes. If none are given, defaults to the currently globally set in
+        xclim.locales.LOCALES
+    names : Optional[Sequence[str]]
+        If given, only returns translations of attributes in this list.
+    fill_missing : bool
+        If True (default), fill untranslated attributes by the default (english) ones.
+    append_locale_name : bool
+        If True (default), append the language tag (as "{attr_name}_{locale}") to the
+        returned attributes.
+
+    Raises
+    ------
+    ValueError
+        If `append_locale_name` is False and multiple `locales` are requested.
+        .
+
+    Returns
+    -------
+    dict
+        All CF attributes available for given indicator and locales.
+        Warns and returns an empty dict if none were available.
+    """
+    if not locales:
+        locales = LOCALES
+
+    if not append_locale_name and len(locales) > 1:
+        raise ValueError(
+            "`append_locale_name` cannot be False if multiple locales are requested."
+        )
+
+    attrs = {}
+    for locale in locales:
+        loc_name, loc_dict = get_local_dict(locale)
+        loc_name = f"_{loc_name}" if append_locale_name else ""
+        ind_name = f"{indicator.__module__.split('.')[1]}.{indicator.identifier}"
+        local_attrs = loc_dict.get(ind_name)
+        if local_attrs is None:
+            warnings.warn(
+                f"Attributes of indicator {ind_name} in language {locale} were requested, but none were found."
+            )
+        else:
+            for name in TRANSLATABLE_ATTRS:
+                if (names is None or name in names) and (
+                    fill_missing or name in local_attrs
+                ):
+                    attrs[f"{name}{loc_name}"] = local_attrs.get(
+                        name, getattr(indicator, name)
+                    )
+    return attrs
+
+
+def get_local_formatter(locale: Union[str, Sequence[str], Tuple[str, dict]]):
+    """Return an AttrFormatter instance for the given locale.
+
+    Parameters
+    ----------
+    locale : str or tuple of str
+        IETF language tag or a tuple of the language tag and a translation dict, or
+        a tuple of the language tag and a path to a json file defining translation
+        of attributes.
+    """
+    loc_name, loc_dict = get_local_dict(locale)
+    attrs_mapping = loc_dict["attrs_mapping"].copy()
+    mods = attrs_mapping.pop("modifiers")
+    return AttrFormatter(attrs_mapping, mods)
+
+
+def set_locales(*locales: Union[str, Sequence[str], Tuple[str, dict]]):
+    """Set the current locales.
+
+    All indicators computed through atmos, land or seaIce will have additionnal metadata
+    in the given languages, as available and defined in xclim.locales data files or in given file.
+
+    Parameters
+    ----------
+    *locales : str or tuple of str
+        IETF language tag or a tuple of the language tag and a translation dict, or
+        a tuple of the language tag and a path to a json file defining translation
+        of attributes.
+
+    Raises
+    ------
+    UnavailableLocaleError
+        If a requested locale is not available.
+    """
+    for locale in locales:
+        if (isinstance(locale, str) and get_best_locale(locale) is None) or (
+            not isinstance(locale, str)
+            and isinstance(locale[1], str)
+            and not Path(locale[1]).is_file()
+        ):
+            raise UnavailableLocaleError(locale)
+    LOCALES[:] = locales
+
+
+class metadata_locale:
+    """Set a locale for the metadata output within a context.
+    """
+
+    def __init__(self, *locales: Union[str, Sequence[str], Tuple[str, dict]]):
+        """Create the context object to manage locales.
+
+        Parameters
+        ----------
+        **locales : str
+            IETF language tag or a tuple of the language tag and a translation dict, or
+            a tuple of the language tag and a path to a json file defining translation
+            of attributes.
+
+        Raises
+        ------
+        UnavailableLocaleError
+            If a requested locale is not defined in `xclim.locales`.
+
+        Examples
+        --------
+        >>> with metadata_locale("fr", "de"):
+        >>>     gdd = atmos.growing_degree_days(ds.tas)  # Will be created with english, french and german metadata.
+        >>> gdd = atmos.growing_degree_days(ds.tas)  # Will be created with english metadata only.
+        """
+
+        self.locales = locales
+
+    def __enter__(self):
+        self.old_locales = LOCALES[:]
+        set_locales(*self.locales)
+
+    def __exit__(self, *args, **kwargs):
+        set_locales(*self.old_locales)
+
+
+class UnavailableLocaleError(ValueError):
+    """Error raised when a locale is requested but doesn"t exist.
+    """
+
+    def __init__(self, locale):
+        super().__init__(
+            f"Locale {locale} not available. Use `xclim.locales.list_locales()` to see available languages."
+        )
+
+
+def generate_local_dict(locale: str, init_english: bool = False):
+    """Generate a dictionary with keys for each indicators and translatable attributes.
+
+    Parameters
+    ----------
+    locale : str
+        Locale in the IETF format
+    init_english : bool
+        If True, fills the initial dictionary with the english versions of the attributes.
+        Defaults to False.
+    """
+    import xclim as xc
+
+    indicators = {}
+    for module in [xc.atmos, xc.land, xc.seaIce]:
+        for indicator in module.__dict__.values():
+            if not isinstance(
+                indicator, (xc.indicator.Indicator, xc.indicator.Indicator2D)
+            ):
+                continue
+            ind_name = f"{indicator.__module__.split('.')[1]}.{indicator.identifier}"
+            indicators[ind_name] = indicator
+
+    best_locale = get_best_locale(locale)
+    if best_locale is not None:
+        locname, attrs = get_local_dict(best_locale)
+        for ind_name in attrs.copy().keys():
+            if ind_name not in indicators:
+                attrs.pop(ind_name)
+    else:
+        attrs = {}
+
+    attrs_mapping = attrs.setdefault("attrs_mapping", {})
+    attrs_mapping.setdefault("modifiers", [""])
+    for key, value in xc.utils.default_formatter.mapping.items():
+        attrs_mapping.setdefault(key, [value[0]])
+
+    eng_attr = ""
+    for ind_name, indicator in indicators.items():
+        ind_attrs = attrs.setdefault(ind_name, {})
+        for translatable_attr in TRANSLATABLE_ATTRS:
+            if init_english:
+                eng_attr = getattr(indicator, translatable_attr)
+                if not isinstance(eng_attr, str):
+                    eng_attr = ""
+            ind_attrs.setdefault(f"{translatable_attr}", eng_attr)
+    return attrs
diff --git a/xclim/locales/fr.json b/xclim/locales/fr.json
new file mode 100644
index 00000000..0beeffc7
--- /dev/null
+++ b/xclim/locales/fr.json
@@ -0,0 +1,410 @@
+{
+  "attrs_mapping": {
+    "modifiers": ["m", "f", "mpl", "fpl"],
+    "YS": ["annuel", "annuelle", "annuels", "annuelles"],
+    "MS": ["mensuel", "mensuelle", "mensuels", "mensuelles"],
+    "QS-DEC": ["saisonnier", "saisonnière", "saisonniers", "saisonnières"],
+    "DJF": ["hivernal", "hivernale", "hivernaux", "hivernales"],
+    "MAM": ["printanier", "printanière", "printaniers", "printanières"],
+    "JJA": ["estival", "estivale", "estivaux", "estivales"],
+    "SON": ["automnal", "automnale", "automnaux", "automnales"],
+    "norm": ["normal", "normale", "normaux", "normales"],
+    "m1": ["janvier"],
+    "m2": ["février"],
+    "m3": ["mars"],
+    "m4": ["avril"],
+    "m5": ["mai"],
+    "m6": ["juin"],
+    "m7": ["juillet"],
+    "m8": ["août"],
+    "m9": ["septembre"],
+    "m10": ["octobre"],
+    "m11": ["novembre"],
+    "m12": ["decembre"]
+  },
+  "atmos.rain_frzgr": {
+    "long_name": "Nombre de jours de pluie sur sol gelé",
+    "description": "Nombre {freq:m} de jours avec plus de {thresh} de pluie suivant sept jours consécutifs où la température était sous 0°C. Les précipitations sont considérées comme de la pluie lorsque la température moyenne est au-dessus de 0°C.",
+    "title": "Nombre de jours de pluie sur sol gelé",
+    "comment": "",
+    "abstract": "Nombre de jours avec des précipitations au-dessus d'un certain seuil après 7 jours consécutifs où la température était sous 0°C. Les précipitations sont considérées comme de la pluie lorsque la température moyenne est au-dessus de 0°C."
+  },
+  "atmos.rx1day": {
+    "long_name": "Précipitation maximale en 1 jour",
+    "description": "Précipitation maximale en 1 jour {freq:f}",
+    "title": "Précipitation maximale en 1 jour dans une période donnée.",
+    "comment": "",
+    "abstract": "Maximum des précipitations totales quotidiennes pour une période donnée."
+  },
+  "atmos.max_n_day_precipitation_amount": {
+    "long_name": "Précipitation maximale sur {window} jours",
+    "description": "Précipitation maximale sur {window} jours {freq:f}",
+    "title": "Précipitation maximale cumulée sur un nombre de jours donné.",
+    "comment": "",
+    "abstract": "Maximum de la somme mobile des précipitations quotidiennes pour une période donnée."
+  },
+  "atmos.wetdays": {
+    "long_name": "Nombre de jours pluvieux (précip >= {thresh})",
+    "description": "Nombre {freq:m} de jours où les précipitations sont au-dessus de {thresh}.",
+    "title": "Jours mouillés",
+    "comment": "",
+    "abstract": "Nombre de jours où les précipitations sont au-dessus d'un seuil."
+  },
+  "atmos.cdd": {
+    "long_name": "Durée maximale d'une période sèche",
+    "description": "Nombre {freq:m} maximal de jours consécutifs où les précipitations sont sous {thresh}.",
+    "title": "Nombre maximal de jours secs consécutifs",
+    "comment": "",
+    "abstract": "Période la plus longue où la précipitation est sous un seuil."
+  },
+  "atmos.cwd": {
+    "long_name": "Durée maximale d'une période pluvieuse",
+    "description": "Nombre {freq:m} maximal de jours consécutifs où les précipitations sont au-dessus de {thresh}.",
+    "title": "Durée de période pluvieuse",
+    "comment": "",
+    "abstract": "Période la plus longue où la précipitation est au-dessus d'un seuil."
+  },
+  "atmos.sdii": {
+    "long_name": "Indice simple de l'intensité des précipitations",
+    "description": "Indice simple d'intensité des précipitations {freq:m} [SDII]. Moyenne {freq:f} de la précipitation journalière pour les jours où la précipitation est au-dessus de {thresh}.",
+    "title": "Indice simple de l'intensité des précipitations",
+    "comment": "",
+    "abstract": "Moyenne de la précipitation quotidienne pour les jours où la précipitation est au-dessus d'un seuil."
+  },
+  "atmos.prcptot": {
+    "long_name": "Précipitation totale",
+    "description": "Précipitation totale {freq:f}",
+    "title": "Précipitation accumulée totale (liquide ou solide)",
+    "comment": "",
+    "abstract": "Précipitation accumulée totale. Si la température journalière moyenne est donnée, le paramètre phase peut-être utilisé pour restreindre le calcul aux précipitations d'une seule phase (liquide ou solide). Les précipitations sont considérées solides si la température journalière moyenne est sous 0°C (et vice-versa)."
+  },
+  "atmos.liquidprcptot": {
+    "long_name": "Précipitation liquide totale",
+    "description": "Précipitation liquide totale {freq:f}, estimée comme la précipitation lorsque la température moyenne est >= 0°C",
+    "title": "Précipitation liquide accumulée totale",
+    "comment": "",
+    "abstract": "Précipitation liquide accumulée totale. Les précipitations sont considérées liquides lorsque la température journalière moyenne est au-dessus de 0°C."
+  },
+  "atmos.solidprcptot": {
+    "long_name": "Précipitation solide totale",
+    "description": "Précipitation solide totale {freq:f}, estimée comme la précipitation lorsque la température moyenne est < 0°C",
+    "title": "Précipitation solide accumulée totale",
+    "comment": "",
+    "abstract": "Précipitation liquide accumulée totale. Les précipitations sont considérées liquides lorsque la température journalière moyenne est sous 0°C."
+  },
+  "atmos.DC": {
+    "long_name": "Indice de sécheresse",
+    "description": "Code numérique estimant la teneur en humidité moyenne de couches organiques. Calculée avec la méthode de mise en route '{start_up_mode}'",
+    "title": "Indice de sécheresse quotidien",
+    "comment": "",
+    "abstract": "L'indice de sécheresse fait partie du système canadien des Indices Forêt-Météo. C'est un code numérique estimant la teneur en humidité moyenne de couches organiques."
+  },
+  "atmos.tn_days_below": {
+    "long_name": "Nombre de jours avec Tmin < {thresh}",
+    "description": "Nombre {freq:m} de jours où la température journalière minimale est sous {thresh}",
+    "title": "Nombre de jours avec tmin sous un certain seuil",
+    "comment": "",
+    "abstract": "Nombre de jours où la température journalière minimale est sous un seuil."
+  },
+  "atmos.tx_days_above": {
+    "long_name": "Nombre de jours avec Tmax > {thresh}",
+    "description": "Nombre {freq:m} de jours où la température journalière maximale est au-dessus de {thresh}",
+    "title": "Nombre de jours avec tmax au-dessus d'un certain seuil",
+    "comment": "",
+    "abstract": "Nombre de jours où la température journalière maximale est au-dessus d'un seuil."
+  },
+  "atmos.tx_tn_days_above": {
+    "long_name": "Nombre de jours avec Tmax > {thresh_tasmax} et Tmin > {thresh_tasmin}",
+    "description": "Nombre {freq:m} de jours où la température journalière maximale excède {thresh_tasmax} et la température journalière minimale excède {thresh_tasmin}",
+    "title": "Nombre de jours avec des températures quotidiennes minimales et maximales chaudes",
+    "comment": "",
+    "abstract": "Nombre de jours où les températures journalières maximales et minimales sont au-dessus de seuils donnés."
+  },
+  "atmos.heat_wave_frequency": {
+    "long_name": "Nombre de vagues de chaleur (Tmin > {thresh_tasmin} et Tmax > {thresh_tasmax} pour {window} jours)",
+    "description": "Nombre {freq:m} de vagues de chaleur. Une vague de chaleur se produit lorsque les températures quotidiennes minimales et maximales excèdent {thresh_tasmin} et {thresh_tasmax}, respectivement, durant au moins {window} jours.",
+    "title": "Fréquence des vagues de chaleur",
+    "comment": "",
+    "abstract": "Nombre de vagues de chaleur. Une vague de chaleur se produit lorsque les températures journalières minimales et maximales excèdent des seuils donnés durant un certain nombre de jours."
+  },
+  "atmos.heat_wave_total_length": {
+    "long_name": "Durée totale des vagues de chaleur (Tmin > {thresh_tasmin} et Tmax > {thresh_tasmax} pour >= {window} days)",
+    "description": "Durée totale {freq:f} des vagues de chaleur. UnAn event occurs when the minimum and maximum daily temperature both exceeds specific thresholds (Tmin > {thresh_tasmin} and Tmax > {thresh_tasmax}) over a minimum number of days ({window}).",
+    "comment": "",
+    "title": "Durée totale des vagues de chaleur",
+    "abstract": "Durée totale des vagues de chaleur. Une vague de chaleur se produit lorsque les températures journalières minimales et maximales excèdent des seuils donnés durant un certain nombre de jours."
+  },
+  "atmos.heat_wave_max_length": {
+    "long_name": "Longueur maximale des vagues de chaleur (Tmin > {thresh_tasmin} et Tmax > {thresh_tasmax} pour {window} jours au moins)",
+    "description": "Longueur maximale {freq:f} des vagues de chaleur. Une vague de chaleur se produit lorsque les températures quotidiennes minimales et maximales excèdent {thresh_tasmin} et {thresh_tasmax}, respectivement, durant au moins {window} jours.",
+    "comment": "",
+    "title": "",
+    "abstract": "Longueur maximale des vagues de chaleur. Une vague de chaleur se produit lorsque les températures journalières minimales et maximales excèdent des seuils donnés durant un certain nombre de jours."
+  },
+  "atmos.heat_wave_index": {
+    "long_name": "Nombre de jours de vague de chaleur",
+    "description": "Nombre {freq:m} de jours de vague de chaleur, définie comme cinq jours consécutifs ou plus où la température est au-dessus de {thresh}.",
+    "title": "Indice de vague de chaleur",
+    "comment": "",
+    "abstract": "Nombre de jours de vagues de chaleur. Une vague de chaleur se produit lorsque la température journalière maximale excède un seuil durant au moins 5 jours."
+  },
+  "atmos.tg": {
+    "long_name": "température journalière moyenne",
+    "description": "température journalière moyenne estimée par la médiane des températures maximales et minimales.",
+    "title": "Moyenne des températures maximales et minimales",
+    "comment": "",
+    "abstract": "La température journalière moyenne en assumant une distribution symétrique des températures.  Tg = (Tx + Tn) / 2"
+  },
+  "atmos.tg_mean": {
+    "long_name": "Moyenne de la température journalière moyenne",
+    "description": "Moyenne {freq:f} de la température journalière moyenne",
+    "title": "Moyenne de la température journalière moyenne",
+    "comment": "",
+    "abstract": "Moyenne de la température journalière moyenne."
+  },
+  "atmos.tg10p": {
+    "long_name": "Nombre de jours où la température est au-dessus du 10e percentile",
+    "description": "Nombre {freq:m} de jours où la température est au-dessus du 10e percentile. Le 10e percentile est calculé à partir d'une fenêtre de 5 jours centrée sur chaque jour du calendrier à l'intérieur d'une période de référence.",
+    "title": "Nombre de jours où la température est au-dessus du 10e percentile",
+    "comment": "",
+    "abstract": "Nombre de jours où la température est au-dessus du 10e percentile"
+  },
+  "atmos.tg90p": {
+    "long_name": "Nombre de jours où la température est au-dessus du 90e percentile",
+    "description": "Nombre {freq:m} de jours où la température est au-dessus du 90e percentile. Le 90e percentile est calculé à partir d'une fenêtre de 5 jours centrée sur chaque jour du calendrier à l'intérieur d'une période de référence.",
+    "title": "Nombre de jours où la température est au-dessus du 90e percentile",
+    "comment": "",
+    "abstract": "Number of days with daily mean temperature over the 90th percentile."
+  },
+  "atmos.tn_min": {
+    "long_name": "Minimum de la température journalière minimale",
+    "description": "Minimum {freq:m} de la température journalière minimale",
+    "title": "Minimum de la température minimale",
+    "comment": "",
+    "abstract": "Minimum de la température journalière minimale."
+  },
+  "atmos.tn_max": {
+    "long_name": "Maximum de la température journalière minimale",
+    "description": "Maximum {freq:m} de la température journalière minimale",
+    "title": "Maximum de la température minimale",
+    "comment": "",
+    "abstract": "Maximum de la température journalière minimale."
+  },
+  "atmos.tn_mean": {
+    "long_name": "Moyenne de la température journalière minimale",
+    "description": "Moyenne {freq:f} de la température journalière minimale",
+    "title": "Moyenne de la température minimale",
+    "comment": "",
+    "abstract": "Moyenne de la température journalière minimale."
+  },
+  "atmos.tn10p": {
+    "long_name": "Nombre de jours où la température minimale est au-dessus du 10e percentile",
+    "description": "Nombre {freq:m} de jours où la température minimale est au-dessus du 10e percentile. Le 10e percentile est calculé à partir d'une fenêtre de 5 jours centrée sur chaque jour du calendrier à l'intérieur d'une période de référence.",
+    "title": "Nombre de jours où la température minimale est au-dessus du 10e percentile",
+    "comment": "",
+    "abstract": "Nombre de jours où la température minimale est au-dessus du 10e percentile."
+  },
+  "atmos.tn90p": {
+    "long_name": "Nombre de jours où la température minimale est au-dessus du 90e percentile",
+    "description": "Nombre {freq:m} de jours où la température minimale est au-dessus du 90e percentile. Le 90e percentile est calculé à partir d'une fenêtre de 5 jours centrée sur chaque jour du calendrier à l'intérieur d'une période de référence.",
+    "title": "Nombre de jours où la température minimale est au-dessus du 90e percentile",
+    "comment": "",
+    "abstract": "Nombre de jours où la température minimale est au-dessus du 90e percentile."
+  },
+  "atmos.tx_min": {
+    "long_name": "Minimum de la température journalière maximale",
+    "description": "Minimum {freq:m} de la température journalière maximale",
+    "title": "Minimum de la température maximale",
+    "comment": "",
+    "abstract": "Minimum de la température journalière maximale."
+  },
+  "atmos.tx_max": {
+    "long_name": "Maximum de la température journalière maximale",
+    "description": "Maximum {freq:m} de la température journalière maximale",
+    "title": "Maximum de la température maximale",
+    "comment": "",
+    "abstract": "Maximum de la température journalière maximale."
+  },
+  "atmos.tx_mean": {
+    "long_name": "Moyenne de la température journalière maximale",
+    "description": "Moyenne {freq:f} de la température journalière maximale",
+    "title": "Moyenne de la température maximale",
+    "comment": "",
+    "abstract": "Moyenne de la température journalière maximale."
+  },
+  "atmos.tx10p": {
+    "long_name": "Nombre de jours où la température maximale est au-dessus du 10e percentile",
+    "description": "Nombre {freq:m} de jours où la température maximale est au-dessus du 10e percentile. Le 10e percentile est calculé à partir d'une fenêtre de 5 jours centrée sur chaque jour du calendrier à l'intérieur d'une période de référence.",
+    "title": "Nombre de jours où la température maximale est au-dessus du 10e percentile",
+    "comment": "",
+    "abstract": "Nombre de jours où la température maximale est au-dessus du 10e percentile."
+  },
+  "atmos.tx90p": {
+    "long_name": "Nombre de jours où la température maximale est au-dessus du 90e percentile",
+    "description": "Nombre {freq:m} de jours où la température maximale est au-dessus du 90e percentile. Le 90e percentile est calculé à partir d'une fenêtre de 5 jours centrée sur chaque jour du calendrier à l'intérieur d'une période de référence.",
+    "title": "Nombre de jours où la température maximale est au-dessus du 90e percentile",
+    "comment": "",
+    "abstract": "Nombre de jours où la température maximale est au-dessus du 90e percentile."
+  },
+  "atmos.dtr": {
+    "long_name": "Amplitude de la température diurne",
+    "description": "Moyenne {freq:f} de l'amplitude diurne de température",
+    "comment": "",
+    "title": "Amplitude de la température diurne",
+    "abstract": "La différence moyenne entre la température journalière maximale et la température journalière minimale."
+  },
+  "atmos.dtrvar": {
+    "long_name": "Variabilité de l'amplitude de la température diurne",
+    "description": "Variabilité {freq:f} de l'amplitude de la température diurne (définie comme la moyenne de la variation journalière de l'amplitude de température sur une période donnée)",
+    "title": "Variation quotidienne absolue moyenne de l'amplitude de la température diurne",
+    "comment": "",
+    "abstract": "La valeur absolue de la moyenne de l'amplitude de la température diurne."
+  },
+  "atmos.etr": {
+    "long_name": "Amplitude des températures extrêmes",
+    "description": "Calcul {freq:m} de l'intervalle entre le maximum de la température journalière maximale et le minimum de la température journalière minimale",
+    "title": "Intervalle des températures extrêmes",
+    "comment": "",
+    "abstract": "Le maximum de la température maximale moins le minimum de la température minimale."
+  },
+  "atmos.cold_spell_duration_index": {
+    "long_name": "Indice de durée des vagues de froid",
+    "description": "Nombre {freq:m} de jours de vague de froid. Une vague de froid se produit lorsque la température journalière minimale est sous le 10e percentile durant au moins {window} jours consécutifs. Le 10e percentile est calculé à partir d'une fenêtre de 5 jours centrée sur chaque jour du calendrier à l'intérieur d'une période de référence.",
+    "title": "Indice de durée des vagues de froid",
+    "comment": "",
+    "abstract": "Nombre de jours de vagues de froid. Une vague de froid se produit lorsque la température journalière minimale est sous le 10e percentile durant au moins un certain nombre de jours consécutifs."
+  },
+  "atmos.cold_spell_days": {
+    "long_name": "Jours de vagues de froid",
+    "description": "Nombre {freq:m} de jours de vague de froid. Une vague de froid se produit lorsque la température journalière minimale est sous {thresh} durant au moins {window} jours consécutifs.",
+    "title": "Jours de vague de froid",
+    "comment": "",
+    "abstract": "Nombre de jours de vagues de froid. Une vague de froid se produit lorsque la température journalière minimale est sous un seuil durant au moins un certain nombre de jours consécutifs."
+  },
+  "atmos.dlyfrzthw": {
+    "long_name": "Cycles de gel-dégel quotidien",
+    "description": "Nombre {freq:m} de jours avec un gel-dégel : Tmax > {thresh_tasmax} et Tmin <= {thresh_tasmin}.",
+    "title": "Nombre de jours avec un cycle de gel-dégel",
+    "comment": "",
+    "abstract": "Le nombre de jours où Tmax est au-dessus d'un seuil et Tmin sous un seuil, habituellement 0°C pour les deux."
+  },
+  "atmos.cooling_degree_days": {
+    "long_name": "Degré-jour de réfrigération (Tmoy > {thresh})",
+    "description": "Cumul {freq:m} des degrés-jours de réfrigération (Température au-dessus de {thresh})",
+    "title": "Degré-jour de réfrigération",
+    "comment": "",
+    "abstract": "Cumul des degrés-jours pour les jours où la température moyenne est au-dessus d'un seuil et que les immeubles doivent être réfrigérés."
+  },
+  "atmos.heating_degree_days": {
+    "long_name": "Degré-jour de chauffe (Tmoy < {thresh})",
+    "description": "Cumul {freq:m} des degrés-jours de chauffe (Température au-dessus de {thresh}).",
+    "title": "Degré-jour de chauffe",
+    "comment": "",
+    "abstract": "Cumul des degrés-jours pour les jours où la température moyenne est sous un seuil et que les immeubles doivent être chauffés."
+  },
+  "atmos.growing_degree_days": {
+    "long_name": "Degré-jour de croissance (Tmoy > {thresh})",
+    "description": "Cumul {freq} des degrés-jours de croissance (Température au-dessus de {thresh})",
+    "title": "Degré-jour de croissance",
+    "comment": "",
+    "abstract": "Cumul des degrés-jours pour les jours où la température moyenne est au-dessus d'un seuil."
+  },
+  "atmos.freshet_start": {
+    "long_name": "Jour de l'année du début de la crue nivale",
+    "description": "Jour de l'année du début de la crue nivale, défini comme le {window}e jour consécutif où la température excède {thresh}.",
+    "title": "Nième jour consécutif où la température excède un seuil",
+    "comment": "",
+    "abstract": "Premier jour où la température excède un certain seuil depuis un certain nombre de jours consécutifs."
+  },
+  "atmos.frost_days": {
+    "long_name": "Nombre de jours de gel (Tmin < 0C)",
+    "description": "Nombre {freq:m} de jours où la température journalière minimale est sous 0°C.",
+    "title": "Jours de gel",
+    "comment": "",
+    "abstract": "Nombre de jours où la température journalière minimale est sous 0°C."
+  },
+  "atmos.ice_days": {
+    "long_name": "Nombre de jours de givre (Tmax < 0°C)",
+    "description": "Nombre {freq:m} de jours où la température journalière maximale est sous 0°C",
+    "title": "Nombre de jours de givre",
+    "comment": "",
+    "abstract": "Nombre de jours où la température journalière maximale est sous 0°C."
+  },
+  "atmos.consecutive_frost_days": {
+    "long_name": "Durée maximale des périodes de gel (Tmin < 0°C)",
+    "description": "Durée maximale {freq:f} des périodes où la température journalière minimale est sous 0°C",
+    "title": "Durée maximale des périodes de gel (Tmin < 0°C)",
+    "comment": "",
+    "abstract": "Durée maximale des périodes consécutives où la température journalière minimale est sous 0°C"
+  },
+  "atmos.growing_season_length": {
+    "long_name": "Durée de la saison de végétation",
+    "description": "Nombre {freq:m} de jours entre la première occurrence d'au moins {window} jours consécutifs où la température journalière moyenne dépasse {thresh} et la première occurrence (après le {mid_date}) d'au moins {window} jours consécutifs où la température est sous {thresh}.",
+    "comment": "",
+    "title": "Durée de la saison de croissance",
+    "abstract": "Nombre de jours entre la première série de N jours avec des températures journalières moyennes au-dessus d'un seuil et la première série de N jours avec des températures journalières moyennes sous ce même seuil."
+  },
+  "atmos.tropical_nights": {
+    "long_name": "Nombre de nuits tropicales (Tmin > {thresh})",
+    "description": "Nombre {freq:m} de nuits tropicales : définies comme des jours avec une température minimale au-dessus de {thresh}",
+    "title": "Nombre de nuits tropicales",
+    "comment": "",
+    "abstract": "Nombre de jours où la température minimale est au-dessus d'un seuil."
+  },
+  "land.base_flow_index": {
+    "long_name": "Flux de base",
+    "description": "Minimum de la moyenne mobile sur 7 jours du flux moyen divisé par le flux moyen",
+    "title": "Flux de base",
+    "comment": "",
+    "abstract": "Minimum de la moyenne mobile sur 7 jours du flux moyen divisé par le flux moyen."
+  },
+  "land.freq_analysis": {
+    "long_name": "Période de retour {mode} {indexer} du flux de {window} jours",
+    "description": "Analyse de fréquence pour le {mode} {indexer} du flux de {window} jours, estimé avec la distribution {dist:f}",
+    "title": "Période de retour",
+    "comment": "",
+    "abstract": "Analyse de fréquence selon un mode et une distribution."
+  },
+  "land.stats": {
+    "long_name": "{op} du {indexer} {freq:m} du flux journalier",
+    "description": "{op} du {indexer} {freq:m} du flux journalier",
+    "title": "Calcul de statistiques sur des sous-périodes.",
+    "comment": "",
+    "abstract": ""
+  },
+  "land.fit": {
+    "long_name": "Paramètres d'une distribution {dist:f}",
+    "description": "Paramètres d'une distribution {dist:f}",
+    "title": "Calcul les paramètres d'une distribution univariée pour un ensemble de données.",
+    "comment": "",
+    "abstract": ""
+  },
+  "land.doy_qmax": {
+    "long_name": "Jour de l'année du maximum le long de {indexer}",
+    "description": "Jour de l'année du maximum le long de {indexer}",
+    "comment": "",
+    "title": "Jour de l'année du maximum.",
+    "abstract": ""
+  },
+  "land.doy_qmin": {
+    "long_name": "Jour de l'année du minimum le long de {indexer}",
+    "description": "Jour de l'année du minimum le long de {indexer}",
+    "comment": "",
+    "title": "Jour de l'année du minimum.",
+    "abstract": ""
+  },
+  "seaIce.sea_ice_area": {
+    "long_name": "Surface de la banquise",
+    "description": "La somme des surfaces recouvertes de glace de mer là où sa concentration est d'au moins {thresh}.",
+    "comment": "",
+    "title": "Surface de la banquise",
+    "abstract": "Mesure de surface totale des océans couverte de glace de mer."
+  },
+  "seaIce.sea_ice_extent": {
+    "long_name": "Étendue de banquise",
+    "description": "L'aire totale de toutes les régions où la concentration de glace de mer est d'au moins {thresh}.",
+    "comment": "",
+    "title": "Étendue de la banquise",
+    "abstract": "Mesure de l'étendue de toutes les régions où la concentration de glace de mer excède un seuil."
+  }
+}
diff --git a/xclim/seaIce/_seaice.py b/xclim/seaIce/_seaice.py
index ad41ebe5..edb9a3ee 100644
--- a/xclim/seaIce/_seaice.py
+++ b/xclim/seaIce/_seaice.py
@@ -6,7 +6,7 @@ import abc
 
 from xclim import checks
 from xclim import indices
-from xclim.utils import Indicator2D
+from xclim.indicator import Indicator2D
 
 __all__ = ["sea_ice_area", "sea_ice_extent"]
 
diff --git a/xclim/utils.py b/xclim/utils.py
index cda8db7c..a4b00754 100644
--- a/xclim/utils.py
+++ b/xclim/utils.py
@@ -4,9 +4,9 @@ xclim xarray.DataArray utilities module
 """
 # import abc
 import calendar
-import datetime as dt
 import functools
 import re
+import string
 import warnings
 from collections import defaultdict
 from collections import OrderedDict
@@ -14,7 +14,9 @@ from datetime import timedelta
 from inspect import signature
 from types import FunctionType
 from typing import Any
+from typing import Mapping
 from typing import Optional
+from typing import Sequence
 from typing import Union
 
 import numpy as np
@@ -33,8 +35,6 @@ from xarray.coding.cftime_offsets import YearEnd
 from xarray.coding.cftimeindex import CFTimeIndex
 from xarray.core.resample import DataArrayResample
 
-import xclim
-from xclim import checks
 
 __all__ = [
     "units",
@@ -51,10 +51,9 @@ __all__ = [
     "get_daily_events",
     "daily_downsampler",
     "walk_map",
-    "Indicator",
-    "Indicator2D",
+    "default_formatter",
+    "AttrFormatter",
     "parse_doc",
-    "format_kwargs",
     "cfindex_start_time",
     "cfindex_end_time",
     "cftime_start_time",
@@ -65,6 +64,7 @@ __all__ = [
     "sfcwind_2_uas_vas",
 ]
 
+
 units = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
 units.define(
     pint.unit.UnitDefinition(
@@ -330,8 +330,6 @@ def _check_units(val: Optional[Union[str, int, float]], dim: Optional[str]) -> N
         return
 
     if str(val).startswith("UNSET "):
-        from warnings import warn
-
         warnings.warn(
             "This index calculation will soon require user-specified thresholds.",
             FutureWarning,
@@ -748,305 +746,86 @@ def walk_map(d: dict, func: FunctionType):
     return out
 
 
-# This class needs to be subclassed by individual indicator classes defining metadata information, compute and
-# missing functions. It can handle indicators with any number of forcing fields.
-class Indicator:
-    r"""Climate indicator based on xarray
-    """
-    # Unique ID for function registry.
-    identifier = ""
-
-    # Output variable name. May use tags {<tag>} that will be formatted at runtime.
-    var_name = ""
-
-    _nvar = 1
-
-    # CF-Convention metadata to be attributed to the output variable. May use tags {<tag>} formatted at runtime.
-    # The set of permissible standard names is contained in the standard name table.
-    standard_name = ""
-    long_name = ""  # Parsed.
-    units = ""  # Representative units of the physical quantity.
-    cell_methods = ""  # List of blank-separated words of the form "name: method"
-    description = ""  # The description is meant to clarify the qualifiers of the fundamental quantities, such as which
-    #   surface a quantity is defined on or what the flux sign conventions are.
-
-    # The `pint` unit context. Use 'hydro' to allow conversion from kg m-2 s-1 to mm/day.
-    context = "none"
-
-    # Additional information that can be used by third party libraries or to describe the file content.
-    title = ""  # A succinct description of what is in the dataset. Default parsed from compute.__doc__
-    abstract = ""  # Parsed
-    keywords = ""  # Comma separated list of keywords
-    # Published or web-based references that describe the data or methods used to produce it. Parsed.
-    references = ""
-    comment = (
-        ""  # Miscellaneous information about the data or methods used to produce it.
-    )
-    notes = ""  # Mathematical formulation. Parsed.
-
-    # Tag mappings between keyword arguments and long-form text.
-    months = {f"m{i}": calendar.month_name[i].lower() for i in range(1, 13)}
-    _attrs_mapping = {
-        "cell_methods": {
-            "YS": "years",
-            "MS": "months",
-        },  # I don't think this is necessary.
-        "long_name": {
-            "YS": "Annual",
-            "MS": "Monthly",
-            "QS-DEC": "Seasonal",
-            "DJF": "winter",
-            "MAM": "spring",
-            "JJA": "summer",
-            "SON": "fall",
-            "norm": "Normal",
-        },
-        "description": {
-            "YS": "Annual",
-            "MS": "Monthly",
-            "QS-DEC": "Seasonal",
-            "DJF": "winter",
-            "MAM": "spring",
-            "JJA": "summer",
-            "SON": "fall",
-            "norm": "Normal",
-        },
-        "var_name": {"DJF": "winter", "MAM": "spring", "JJA": "summer", "SON": "fall"},
-    }
-
-    for k, v in _attrs_mapping.items():
-        v.update(months)
-
-    # Whether or not the compute function is a partial.
-    _partial = False
-
-    # Can be used to override the compute docstring.
-    doc_template = None
-
-    def __init__(self, **kwds):
-
-        # Set instance attributes.
-        for key, val in kwds.items():
-            setattr(self, key, val)
-
-        # Verify that the identifier is a proper slug
-        if not re.match(r"^[-\w]+$", self.identifier):
-            warnings.warn(
-                "The identifier contains non-alphanumeric characters. It could make life "
-                "difficult for downstream software reusing this class.",
-                UserWarning,
-            )
-
-        # Default value for `var_name` is the `identifier`.
-        if self.var_name == "":
-            self.var_name = self.identifier
-
-        # Extract information from the `compute` function.
-        # The signature
-        self._sig = signature(self.compute)
-
-        # The input parameter names
-        self._parameters = tuple(self._sig.parameters.keys())
-        #        self._input_params = [p for p in self._sig.parameters.values() if p.default is p.empty]
-        #        self._nvar = len(self._input_params)
-
-        # Copy the docstring and signature
-        self.__call__ = wraps(self.compute)(self.__call__.__func__)
-        if self.doc_template is not None:
-            self.__call__.__doc__ = self.doc_template.format(i=self)
-
-        # Fill in missing metadata from the doc
-        meta = parse_doc(self.compute.__doc__)
-        for key in ["abstract", "title", "notes", "references"]:
-            setattr(self, key, getattr(self, key) or meta.get(key, ""))
-
-    def __call__(self, *args, **kwds):
-        # Bind call arguments. We need to use the class signature, not the instance, otherwise it removes the first
-        # argument.
-        if self._partial:
-            ba = self._sig.bind_partial(*args, **kwds)
-            for key, val in self.compute.keywords.items():
-                if key not in ba.arguments:
-                    ba.arguments[key] = val
-        else:
-            ba = self._sig.bind(*args, **kwds)
-            ba.apply_defaults()
-
-        # Get history and cell method attributes from source data
-        attrs = defaultdict(str)
-        for i in range(self._nvar):
-            p = self._parameters[i]
-            for attr in ["history", "cell_methods"]:
-                attrs[attr] += f"{p}: " if self._nvar > 1 else ""
-                attrs[attr] += getattr(ba.arguments[p], attr, "")
-                if attrs[attr]:
-                    attrs[attr] += "\n" if attr == "history" else " "
-
-        # Update attributes
-        out_attrs = self.format(self.cf_attrs, ba.arguments)
-        vname = self.format({"var_name": self.var_name}, ba.arguments)["var_name"]
-
-        # Update the signature with the values of the actual call.
-        cp = OrderedDict()
-        for (k, v) in ba.signature.parameters.items():
-            if v.default is not None and isinstance(v.default, (float, int, str)):
-                cp[k] = v.replace(default=ba.arguments[k])
-            else:
-                cp[k] = v
-
-        attrs[
-            "history"
-        ] += "[{:%Y-%m-%d %H:%M:%S}] {}: {}{} - xclim version: {}.".format(
-            dt.datetime.now(),
-            vname,
-            self.identifier,
-            ba.signature.replace(parameters=cp.values()),
-            xclim.__version__,
-        )
-        attrs["cell_methods"] += out_attrs.pop("cell_methods", "")
-        attrs.update(out_attrs)
-
-        # Assume the first arguments are always the DataArray.
-        das = tuple(ba.arguments.pop(self._parameters[i]) for i in range(self._nvar))
-
-        # Pre-computation validation checks
-        for da in das:
-            self.validate(da)
-        self.cfprobe(*das)
-
-        # Compute the indicator values, ignoring NaNs.
-        out = self.compute(*das, **ba.kwargs)
-
-        # Convert to output units
-        out = convert_units_to(out, self.units, self.context)
-
-        # Update netCDF attributes
-        out.attrs.update(attrs)
-
-        # Bind call arguments to the `missing` function, whose signature might be different from `compute`.
-        mba = signature(self.missing).bind(*das, **ba.arguments)
-
-        # Mask results that do not meet criteria defined by the `missing` method.
-        mask = self.missing(*mba.args, **mba.kwargs)
-        ma_out = out.where(~mask)
-
-        return ma_out.rename(vname)
-
-    @property
-    def cf_attrs(self):
-        """CF-Convention attributes of the output value."""
-        names = [
-            "standard_name",
-            "long_name",
-            "units",
-            "cell_methods",
-            "description",
-            "comment",
-            "references",
-        ]
-        return {k: getattr(self, k) for k in names if getattr(self, k)}
-
-    def json(self, args=None):
-        """Return a dictionary representation of the class.
-
-        Notes
-        -----
-        This is meant to be used by a third-party library wanting to wrap this class into another interface.
-
-        """
-        names = ["identifier", "var_name", "abstract", "keywords"]
-        out = {key: getattr(self, key) for key in names}
-        out.update(self.cf_attrs)
-        out = self.format(out, args)
-
-        out["notes"] = self.notes
-
-        out["parameters"] = str(
-            {
-                key: {
-                    "default": p.default if p.default != p.empty else None,
-                    "desc": "",
-                }
-                for (key, p) in self._sig.parameters.items()
-            }
-        )
-
-        # if six.PY2:
-        #     out = walk_map(out, lambda x: x.decode('utf8') if isinstance(x, six.string_types) else x)
-
-        return out
-
-    def cfprobe(self, *das):
-        """Check input data compliance to expectations.
-        Warn of potential issues."""
-        return True
+class AttrFormatter(string.Formatter):
+    """A formatter for frequently used attribute values.
 
-    def compute(*args, **kwds):
-        """The function computing the indicator."""
-        raise NotImplementedError
+    See the doc of format_field() for more details.
+    """
 
-    def format(self, attrs: dict, args: dict = None):
-        """Format attributes including {} tags with arguments.
+    def __init__(
+        self, mapping: Mapping[str, Sequence[str]], modifiers: Sequence[str],
+    ):
+        """Initialize the formatter.
 
         Parameters
         ----------
-        attrs: dict
-          Attributes containing tags to replace with arguments' values.
-        args : dict
-          Function call arguments.
+        mapping : Mapping[str, Sequence[str]]
+            A mapping from values to their possible variations.
+        modifiers : Sequence[str]
+            The list of modifiers, must be the as long as the longest value of `mapping`.
         """
-        if args is None:
-            return attrs
-
-        out = {}
-        for key, val in attrs.items():
-            mba = {"indexer": "annual"}
-            # Add formatting {} around values to be able to replace them with _attrs_mapping using format.
-            for k, v in args.items():
-                if isinstance(v, str) and v in self._attrs_mapping.get(key, {}).keys():
-                    mba[k] = "{{{}}}".format(v)
-                elif isinstance(v, dict):
-                    if v:
-                        dk, dv = v.copy().popitem()
-                        if dk == "month":
-                            dv = "m{}".format(dv)
-                        mba[k] = "{{{}}}".format(dv)
-                elif isinstance(v, units.Quantity):
-                    mba[k] = "{:g~P}".format(v)
-                elif isinstance(v, (int, float)):
-                    mba[k] = "{:g}".format(v)
-                else:
-                    mba[k] = v
-
-            if callable(val):
-                val = val(**mba)
-
-            out[key] = val.format(**mba).format(**self._attrs_mapping.get(key, {}))
-
-        return out
-
-    @staticmethod
-    def missing(*args, **kwds):
-        """Return whether an output is considered missing or not."""
-        from functools import reduce
-
-        freq = kwds.get("freq")
-        if freq is not None:
-            # We flag any period with missing data
-            miss = (checks.missing_any(da, freq) for da in args)
-        else:
-            # There is no resampling, we flag where one of the input is missing
-            miss = (da.isnull() for da in args)
-        return reduce(np.logical_or, miss)
-
-    def validate(self, da):
-        """Validate input data requirements.
-        Raise error if conditions are not met."""
-        checks.assert_daily(da)
-
+        super().__init__()
+        self.modifiers = modifiers
+        self.mapping = mapping
+
+    def format_field(self, value, format_spec):
+        """Format a value given a formatting spec.
+
+        If `format_spec` is in this Formatter's modifiers, the correspong variation
+        of value is given. If `format_spec` is not specified but `value` is in the
+        mapping, the first variation is returned.
+
+        Example
+        -------
+        Let's say the string "The dog is {adj1}, the goose is {adj2}" is to be translated
+        to french and that we know that possible values of `adj` are `nice` and `evil`.
+        In french, the genre of the noun changes the adjective (cat = chat is masculine,
+        and goose = oie is feminine) so we initialize the formatter as:
+
+        >>> fmt = AttrFormatter({'nice': ['beau', 'belle'], 'evil' : ['méchant', 'méchante']},
+                                ['m', 'f'])
+        >>> fmt.format("Le chien est {adj1:m}, l'oie est {adj2:f}",
+                       adj1='nice', adj2='evil')
+        "Le chien est beau, l'oie est méchante"
+        """
+        if value in self.mapping and not format_spec:
+            return self.mapping[value][0]
 
-class Indicator2D(Indicator):
-    _nvar = 2
+        if format_spec in self.modifiers:
+            if value in self.mapping:
+                return self.mapping[value][self.modifiers.index(format_spec)]
+            raise ValueError(
+                f"No known mapping for string '{value}' with modifier '{format_spec}'"
+            )
+        return super().format_field(value, format_spec)
+
+
+# Tag mappings between keyword arguments and long-form text.
+default_formatter = AttrFormatter(
+    {
+        "YS": ["annual", "years"],
+        "MS": ["monthly", "months"],
+        "QS-DEC": ["seasonal", "seasons"],
+        "DJF": ["winter"],
+        "MAM": ["spring"],
+        "JJA": ["summer"],
+        "SON": ["fall"],
+        "norm": ["Normal"],
+        "m1": ["january"],
+        "m2": ["february"],
+        "m3": ["march"],
+        "m4": ["april"],
+        "m5": ["may"],
+        "m6": ["june"],
+        "m7": ["july"],
+        "m8": ["august"],
+        "m9": ["september"],
+        "m10": ["october"],
+        "m11": ["november"],
+        "m12": ["december"],
+    },
+    ["adj", "noun"],
+)
 
 
 def parse_doc(doc):
@@ -1080,34 +859,6 @@ def parse_doc(doc):
     return out
 
 
-def format_kwargs(attrs: dict, params: dict) -> None:
-    """Modify attribute with argument values.
-
-    Parameters
-    ----------
-    attrs : dict
-      Attributes to be assigned to function output. The values of the attributes in braces will be replaced the
-      the corresponding args values.
-    params : dict
-      A BoundArguments.arguments dictionary storing a function's arguments.
-    """
-    attrs_mapping = {
-        "cell_methods": {"YS": "years", "MS": "months"},
-        "long_name": {"YS": "Annual", "MS": "Monthly"},
-    }
-
-    for key, val in attrs.items():
-        mba = {}
-        # Add formatting {} around values to be able to replace them with _attrs_mapping using format.
-        for k, v in params.items():
-            if isinstance(v, str) and v in attrs_mapping.get(key, {}).keys():
-                mba[k] = "{" + v + "}"
-            else:
-                mba[k] = v
-
-        attrs[key] = val.format(**mba).format(**attrs_mapping.get(key, {}))
-
-
 def cftime_start_time(date, freq):
     """
     Get the cftime.datetime for the start of a period. As we are not supplying


Test patch:
diff --git a/tests/test_indicators.py b/tests/test_indicators.py
new file mode 100644
index 00000000..2674d22b
--- /dev/null
+++ b/tests/test_indicators.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Tests for the Indicator objects
+import dask
+import numpy as np
+import pytest
+
+from xclim import __version__
+from xclim import atmos
+from xclim.indicator import Indicator
+from xclim.utils import units
+
+
+class UniIndTemp(Indicator):
+    identifier = "tmin"
+    var_name = "tmin{thresh}"
+    units = "K"
+    long_name = "{freq} mean surface temperature"
+    standard_name = "{freq} mean temperature"
+    cell_methods = "time: mean within {freq:noun}"
+
+    @staticmethod
+    def compute(da, thresh=0.0, freq="YS"):
+        """Docstring"""
+        out = da
+        out -= thresh
+        return out.resample(time=freq).mean(keep_attrs=True)
+
+
+class UniIndPr(Indicator):
+    identifier = "prmax"
+    units = "mm/s"
+    context = "hydro"
+
+    @staticmethod
+    def compute(da, freq):
+        """Docstring"""
+        return da.resample(time=freq).mean(keep_attrs=True)
+
+
+def test_attrs(tas_series):
+    import datetime as dt
+
+    a = tas_series(np.arange(360.0))
+    ind = UniIndTemp()
+    txm = ind(a, thresh=5, freq="YS")
+    assert txm.cell_methods == "time: mean within days time: mean within years"
+    assert f"{dt.datetime.now():%Y-%m-%d %H}" in txm.attrs["history"]
+    assert "tmin(da, thresh=5, freq='YS')" in txm.attrs["history"]
+    assert f"xclim version: {__version__}." in txm.attrs["history"]
+    assert txm.name == "tmin5"
+
+
+def test_temp_unit_conversion(tas_series):
+    a = tas_series(np.arange(360.0))
+    ind = UniIndTemp()
+    txk = ind(a, freq="YS")
+
+    ind.units = "degC"
+    txc = ind(a, freq="YS")
+
+    np.testing.assert_array_almost_equal(txk, txc + 273.15)
+
+
+def test_json(pr_series):
+    ind = UniIndPr()
+    meta = ind.json()
+
+    expected = {
+        "identifier",
+        "var_name",
+        "units",
+        "long_name",
+        "standard_name",
+        "cell_methods",
+        "keywords",
+        "abstract",
+        "parameters",
+        "description",
+        "history",
+        "references",
+        "comment",
+        "notes",
+    }
+
+    assert set(meta.keys()).issubset(expected)
+
+
+def test_signature():
+    from inspect import signature
+
+    ind = UniIndTemp()
+    assert signature(ind.compute) == signature(ind.__call__)
+
+
+def test_doc():
+    ind = UniIndTemp()
+    assert ind.__call__.__doc__ == ind.compute.__doc__
+
+
+def test_delayed(tasmax_series):
+    tasmax = tasmax_series(np.arange(360.0)).chunk({"time": 5})
+
+    tx = UniIndTemp()
+    txk = tx(tasmax)
+
+    # Check that the calculations are delayed
+    assert isinstance(txk.data, dask.array.core.Array)
+
+    # Same with unit conversion
+    tx.required_units = ("C",)
+    tx.units = "C"
+    txc = tx(tasmax)
+
+    assert isinstance(txc.data, dask.array.core.Array)
+
+
+def test_identifier():
+    with pytest.warns(UserWarning):
+        UniIndPr(identifier="t_{}")
+
+
+def test_formatting(pr_series):
+    out = atmos.wetdays(pr_series(np.arange(366)), thresh=1.0 * units.mm / units.day)
+    # pint 0.10 now pretty print day as d.
+    assert out.attrs["long_name"] in [
+        "Number of wet days (precip >= 1 mm/day)",
+        "Number of wet days (precip >= 1 mm/d)",
+    ]
+    out = atmos.wetdays(pr_series(np.arange(366)), thresh=1.5 * units.mm / units.day)
+    assert out.attrs["long_name"] in [
+        "Number of wet days (precip >= 1.5 mm/day)",
+        "Number of wet days (precip >= 1.5 mm/d)",
+    ]
diff --git a/tests/test_locales.py b/tests/test_locales.py
new file mode 100644
index 00000000..128cebe5
--- /dev/null
+++ b/tests/test_locales.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Tests for `xclim.locales`
+import json
+
+import numpy as np
+import pytest
+
+import xclim.locales as xloc
+from xclim import atmos
+from xclim.utils import default_formatter
+
+
+esperanto = (
+    "eo",
+    {
+        "attrs_mapping": {"modifiers": ["adj"], "YS": ["jara"], "MS": ["monata"]},
+        "atmos.tg_mean": {
+            "long_name": "Meza ciutaga averaga temperaturo",
+            "title": "Meza ciutaga averaga temperaturo",
+        },
+    },
+)
+
+russian = (
+    "ru",
+    {
+        "attrs_mapping": {
+            "modifiers": ["nn", "nf"],
+            "YS": ["годовое", "годовая"],
+            "MS": ["месячный", "месячная"],
+        },
+        "atmos.tg_mean": {
+            "long_name": "Среднее значение среднесуточной температуры.",
+            "description": "Средне{freq:nf} среднесуточная температура.",
+        },
+    },
+)
+
+
+def test_best_locale():
+    assert xloc.get_best_locale("fr") == "fr"
+    assert xloc.get_best_locale("fr-CA") == "fr"
+    assert xloc.get_best_locale("en") is None
+
+
+def test_local_dict(tmp_path):
+    loc, dic = xloc.get_local_dict("fr")
+    assert loc == "fr"
+    assert (
+        dic["atmos.tg_mean"]["long_name"]
+        == "Moyenne de la température journalière moyenne"
+    )
+
+    loc, dic = xloc.get_local_dict(esperanto)
+    assert loc == "eo"
+    assert dic["atmos.tg_mean"]["long_name"] == "Meza ciutaga averaga temperaturo"
+
+    with (tmp_path / "ru.json").open("w") as f:
+        json.dump(russian[1], f, ensure_ascii=False)
+
+    loc, dic = xloc.get_local_dict(("ru", tmp_path / "ru.json"))
+    assert loc == "ru"
+    assert (
+        dic["atmos.tg_mean"]["long_name"]
+        == "Среднее значение среднесуточной температуры."
+    )
+
+    with pytest.raises(xloc.UnavailableLocaleError):
+        xloc.get_local_dict("tlh")
+
+
+@pytest.mark.parametrize(
+    "fill,isin,notin", [(True, ["description"], []), (False, [], ["description"]),]
+)
+def test_local_attrs_sing(fill, isin, notin):
+    attrs = xloc.get_local_attrs(
+        atmos.tg_mean, esperanto, fill_missing=fill, append_locale_name=False
+    )
+    for key in isin:
+        assert key in attrs
+    for key in notin:
+        assert key not in attrs
+
+    with pytest.raises(ValueError):
+        attrs = xloc.get_local_attrs(
+            atmos.tg_mean, "fr", esperanto, append_locale_name=False
+        )
+
+
+@pytest.mark.parametrize(
+    "fill,isin,notin",
+    [
+        (True, ["description_fr", "description_eo", "description_ru"], []),
+        (False, ["description_fr", "description_ru"], ["description_eo"]),
+    ],
+)
+def test_local_attrs_multi(fill, isin, notin, tmp_path):
+    with (tmp_path / "ru.json").open("w") as f:
+        json.dump(russian[1], f, ensure_ascii=False)
+
+    attrs = xloc.get_local_attrs(
+        atmos.tg_mean,
+        "fr",
+        esperanto,
+        ("ru", tmp_path / "ru.json"),
+        fill_missing=fill,
+        append_locale_name=True,
+    )
+    for key in isin:
+        assert key in attrs
+    for key in notin:
+        assert key not in attrs
+
+
+def test_local_formatter():
+    fmt = xloc.get_local_formatter(russian)
+    assert fmt.format("{freq:nn}", freq="YS") == "годовое"
+    assert fmt.format("{freq:nf}", freq="YS") == "годовая"
+
+
+def test_context():
+    assert "fr" not in xloc.LOCALES
+    with xloc.metadata_locale("fr"):
+        assert "fr" in xloc.LOCALES
+    assert "fr" not in xloc.LOCALES
+
+
+@pytest.mark.parametrize("locale", ["tlh", ("tlh", "not/a/real/klingo/file.json")])
+def test_set_locales_error(locale):
+    with pytest.raises(xloc.UnavailableLocaleError):
+        xloc.set_locales(locale)
+
+
+def test_indicator_output(tas_series):
+    tas = tas_series(np.zeros(365))
+
+    with xloc.metadata_locale("fr"):
+        tgmean = atmos.tg_mean(tas, freq="YS")
+
+    assert "long_name_fr" in tgmean.attrs
+    assert (
+        tgmean.attrs["description_fr"]
+        == "Moyenne annuelle de la température journalière moyenne"
+    )
+
+
+def test_indicator_integration():
+    eo_attrs = atmos.tg_mean.translate_attrs(esperanto, fill_missing=True)
+    assert "title" in eo_attrs
+    assert "long_name" in eo_attrs
+
+    eo_attrs = atmos.tg_mean.translate_attrs(esperanto, fill_missing=False)
+    assert "description" not in eo_attrs
+
+
+@pytest.mark.parametrize("locale", xloc.list_locales())
+def test_xclim_translations(locale):
+    loc, dic = xloc.get_local_dict(locale)
+    assert "attrs_mapping" in dic
+    assert "modifiers" in dic["attrs_mapping"]
+    for translatable, translations in dic["attrs_mapping"].items():
+        if translatable != "modifiers":
+            assert isinstance(translations, list)
+            assert len(translations) <= len(dic["attrs_mapping"]["modifiers"])
+    assert set(dic["attrs_mapping"].keys()).symmetric_difference(
+        default_formatter.mapping.keys()
+    ) == {"modifiers"}
+
+    for indicator, fields in dic.items():
+        if indicator != "attrs_mapping":
+            mod, name = indicator.split(".")
+            assert mod in ["atmos", "land", "seaIce"]
+            # no easy to test if indicator exists!
+            assert set(fields.keys()).issubset(xloc.TRANSLATABLE_ATTRS)
+
+
+@pytest.mark.parametrize(
+    "initeng,expected", [(False, ""), (True, atmos.tg_mean.long_name)]
+)
+def test_local_dict_generation(initeng, expected):
+    dic = xloc.generate_local_dict("tlh", init_english=initeng)
+    assert "attrs_mapping" in dic
+    assert "modifiers" in dic["attrs_mapping"]
+    assert dic["atmos.tg_mean"]["long_name"] == expected
diff --git a/tests/test_utils.py b/tests/test_utils.py
index ee15eaa8..d633026d 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -16,7 +16,6 @@
 import os
 
 import cftime
-import dask
 import numpy as np
 import pandas as pd
 import pytest
@@ -24,15 +23,12 @@ import xarray as xr
 from numpy.testing import assert_array_equal
 from xarray.coding.cftimeindex import CFTimeIndex
 
-from xclim import __version__
-from xclim import atmos
 from xclim import indices
-from xclim import subset
 from xclim import utils
 from xclim.utils import adjust_doy_calendar
+from xclim.utils import AttrFormatter
 from xclim.utils import daily_downsampler
-from xclim.utils import format_kwargs
-from xclim.utils import Indicator
+from xclim.utils import default_formatter
 from xclim.utils import infer_doy_max
 from xclim.utils import parse_doc
 from xclim.utils import percentile_doy
@@ -133,143 +129,6 @@ class TestDailyDownsampler:
             assert np.allclose(x2.values, target)
 
 
-class UniIndTemp(Indicator):
-    identifier = "tmin"
-    var_name = "tmin{thresh}"
-    units = "K"
-    long_name = "{freq} mean surface temperature"
-    standard_name = "{freq} mean temperature"
-    cell_methods = "time: mean within {freq}"
-
-    @staticmethod
-    def compute(da, thresh=0.0, freq="YS"):
-        """Docstring"""
-        out = da
-        out -= thresh
-        return out.resample(time=freq).mean(keep_attrs=True)
-
-
-class UniIndPr(Indicator):
-    identifier = "prmax"
-    units = "mm/s"
-    context = "hydro"
-
-    @staticmethod
-    def compute(da, freq):
-        """Docstring"""
-        return da.resample(time=freq).mean(keep_attrs=True)
-
-
-class TestIndicator:
-    def test_attrs(self, tas_series):
-        import datetime as dt
-
-        a = tas_series(np.arange(360.0))
-        ind = UniIndTemp()
-        txm = ind(a, thresh=5, freq="YS")
-        assert txm.cell_methods == "time: mean within days time: mean within years"
-        assert f"{dt.datetime.now():%Y-%m-%d %H}" in txm.attrs["history"]
-        assert "tmin(da, thresh=5, freq='YS')" in txm.attrs["history"]
-        assert f"xclim version: {__version__}." in txm.attrs["history"]
-        assert txm.name == "tmin5"
-
-    def test_temp_unit_conversion(self, tas_series):
-        a = tas_series(np.arange(360.0))
-        ind = UniIndTemp()
-        txk = ind(a, freq="YS")
-
-        ind.units = "degC"
-        txc = ind(a, freq="YS")
-
-        np.testing.assert_array_almost_equal(txk, txc + 273.15)
-
-    def test_json(self, pr_series):
-        ind = UniIndPr()
-        meta = ind.json()
-
-        expected = {
-            "identifier",
-            "var_name",
-            "units",
-            "long_name",
-            "standard_name",
-            "cell_methods",
-            "keywords",
-            "abstract",
-            "parameters",
-            "description",
-            "history",
-            "references",
-            "comment",
-            "notes",
-        }
-
-        assert set(meta.keys()).issubset(expected)
-
-    def test_signature(self):
-        from inspect import signature
-
-        ind = UniIndTemp()
-        assert signature(ind.compute) == signature(ind.__call__)
-
-    def test_doc(self):
-        ind = UniIndTemp()
-        assert ind.__call__.__doc__ == ind.compute.__doc__
-
-    def test_delayed(self):
-        fn = os.path.join(TESTS_DATA, "NRCANdaily", "nrcan_canada_daily_tasmax_1990.nc")
-
-        # Load dataset as a dask array
-        ds = xr.open_dataset(fn, chunks={"time": 10}, cache=True)
-
-        tx = UniIndTemp()
-        txk = tx(ds.tasmax)
-
-        # Check that the calculations are delayed
-        assert isinstance(txk.data, dask.array.core.Array)
-
-        # Same with unit conversion
-        tx.required_units = ("C",)
-        tx.units = "C"
-        txc = tx(ds.tasmax)
-
-        assert isinstance(txc.data, dask.array.core.Array)
-
-    def test_identifier(self):
-        with pytest.warns(UserWarning):
-            UniIndPr(identifier="t_{}")
-
-    def test_formatting(self, pr_series):
-        out = atmos.wetdays(
-            pr_series(np.arange(366)), thresh=1.0 * units.mm / units.day
-        )
-        # pint 0.10 now pretty print day as d.
-        assert out.attrs["long_name"] in [
-            "Number of wet days (precip >= 1 mm/day)",
-            "Number of wet days (precip >= 1 mm/d)",
-        ]
-
-        out = atmos.wetdays(
-            pr_series(np.arange(366)), thresh=1.5 * units.mm / units.day
-        )
-        assert out.attrs["long_name"] in [
-            "Number of wet days (precip >= 1.5 mm/day)",
-            "Number of wet days (precip >= 1.5 mm/d)",
-        ]
-
-
-class TestKwargs:
-    def test_format_kwargs(self):
-        attrs = dict(
-            standard_name="tx_min",
-            long_name="Minimum of daily maximum temperature",
-            cell_methods="time: minimum within {freq}",
-        )
-
-        format_kwargs(attrs, {"freq": "YS"})
-        assert attrs["cell_methods"] == "time: minimum within years"
-
-
 class TestParseDoc:
     def test_simple(self):
         parse_doc(indices.tg_mean.__doc__)
@@ -442,230 +301,6 @@ class TestCheckUnits:
             utils._check_units("m3", "[discharge]")
 
 
-class TestSubsetGridPoint:
-    nc_poslons = os.path.join(
-        TESTS_DATA, "cmip3", "tas.sresb1.giss_model_e_r.run1.atm.da.nc"
-    )
-    nc_file = os.path.join(
-        TESTS_DATA, "NRCANdaily", "nrcan_canada_daily_tasmax_1990.nc"
-    )
-    nc_2dlonlat = os.path.join(TESTS_DATA, "CRCM5", "tasmax_bby_198406_se.nc")
-
-    def test_dataset(self):
-        da = xr.open_mfdataset([self.nc_file, self.nc_file.replace("tasmax", "tasmin")])
-        lon = -72.4
-        lat = 46.1
-        out = subset.subset_gridpoint(da, lon=lon, lat=lat)
-        np.testing.assert_almost_equal(out.lon, lon, 1)
-        np.testing.assert_almost_equal(out.lat, lat, 1)
-        np.testing.assert_array_equal(out.tasmin.shape, out.tasmax.shape)
-
-    def test_simple(self):
-        da = xr.open_dataset(self.nc_file).tasmax
-        lon = -72.4
-        lat = 46.1
-        out = subset.subset_gridpoint(da, lon=lon, lat=lat)
-        np.testing.assert_almost_equal(out.lon, lon, 1)
-        np.testing.assert_almost_equal(out.lat, lat, 1)
-
-        da = xr.open_dataset(self.nc_poslons).tas
-        da["lon"] -= 360
-        yr_st = "2050"
-        yr_ed = "2059"
-
-        out = subset.subset_gridpoint(
-            da, lon=lon, lat=lat, start_date=yr_st, end_date=yr_ed
-        )
-        np.testing.assert_almost_equal(out.lon, lon, 1)
-        np.testing.assert_almost_equal(out.lat, lat, 1)
-        np.testing.assert_array_equal(len(np.unique(out.time.dt.year)), 10)
-        np.testing.assert_array_equal(out.time.dt.year.max(), np.array(int(yr_ed)))
-        np.testing.assert_array_equal(out.time.dt.year.min(), np.array(int(yr_st)))
-
-        # test time only
-        out = subset.subset_gridpoint(da, start_date=yr_st, end_date=yr_ed)
-        np.testing.assert_array_equal(len(np.unique(out.time.dt.year)), 10)
-        np.testing.assert_array_equal(out.time.dt.year.max(), np.array(int(yr_ed)))
-        np.testing.assert_array_equal(out.time.dt.year.min(), np.array(int(yr_st)))
-
-    def test_irregular(self):
-
-        da = xr.open_dataset(self.nc_2dlonlat).tasmax
-        lon = -72.4
-        lat = 46.1
-        out = subset.subset_gridpoint(da, lon=lon, lat=lat)
-        np.testing.assert_almost_equal(out.lon, lon, 1)
-        np.testing.assert_almost_equal(out.lat, lat, 1)
-
-        # test_irregular transposed:
-        da1 = xr.open_dataset(self.nc_2dlonlat).tasmax
-        dims = list(da1.dims)
-        dims.reverse()
-        daT = xr.DataArray(np.transpose(da1.values), dims=dims)
-        for d in daT.dims:
-            args = dict()
-            args[d] = da1[d]
-            daT = daT.assign_coords(**args)
-        daT = daT.assign_coords(lon=(["rlon", "rlat"], np.transpose(da1.lon.values)))
-        daT = daT.assign_coords(lat=(["rlon", "rlat"], np.transpose(da1.lat.values)))
-
-        out1 = subset.subset_gridpoint(daT, lon=lon, lat=lat)
-        np.testing.assert_almost_equal(out1.lon, lon, 1)
-        np.testing.assert_almost_equal(out1.lat, lat, 1)
-        np.testing.assert_array_equal(out, out1)
-
-        # Dataset with tasmax, lon and lat as data variables (i.e. lon, lat not coords of tasmax)
-        daT1 = xr.DataArray(np.transpose(da1.values), dims=dims)
-        for d in daT1.dims:
-            args = dict()
-            args[d] = da1[d]
-            daT1 = daT1.assign_coords(**args)
-        dsT = xr.Dataset(data_vars=None, coords=daT1.coords)
-        dsT["tasmax"] = daT1
-        dsT["lon"] = xr.DataArray(np.transpose(da1.lon.values), dims=["rlon", "rlat"])
-        dsT["lat"] = xr.DataArray(np.transpose(da1.lat.values), dims=["rlon", "rlat"])
-        out2 = subset.subset_gridpoint(dsT, lon=lon, lat=lat)
-        np.testing.assert_almost_equal(out2.lon, lon, 1)
-        np.testing.assert_almost_equal(out2.lat, lat, 1)
-        np.testing.assert_array_equal(out, out2.tasmax)
-
-    def test_positive_lons(self):
-        da = xr.open_dataset(self.nc_poslons).tas
-        lon = -72.4
-        lat = 46.1
-        out = subset.subset_gridpoint(da, lon=lon, lat=lat)
-        np.testing.assert_almost_equal(out.lon, lon + 360, 1)
-        np.testing.assert_almost_equal(out.lat, lat, 1)
-
-        out = subset.subset_gridpoint(da, lon=lon + 360, lat=lat)
-        np.testing.assert_almost_equal(out.lon, lon + 360, 1)
-        np.testing.assert_almost_equal(out.lat, lat, 1)
-
-    def test_type_warn_then_raise(self):
-        da = xr.open_dataset(self.nc_poslons).tas
-        with pytest.raises(ValueError):
-            with pytest.warns(Warning):
-                subset.subset_gridpoint(
-                    da, lon=-72.4, lat=46.1, start_date=2056, end_date=2055
-                )
-
-    def test_raise(self):
-        da = xr.open_dataset(self.nc_2dlonlat).tasmax.drop(["lon", "lat"])
-        with pytest.raises(Exception):
-            subset.subset_gridpoint(da, lon=-72.4, lat=46.1)
-
-
-class TestSubsetBbox:
-    nc_poslons = os.path.join(
-        TESTS_DATA, "cmip3", "tas.sresb1.giss_model_e_r.run1.atm.da.nc"
-    )
-    nc_file = os.path.join(
-        TESTS_DATA, "NRCANdaily", "nrcan_canada_daily_tasmax_1990.nc"
-    )
-    nc_2dlonlat = os.path.join(TESTS_DATA, "CRCM5", "tasmax_bby_198406_se.nc")
-    lon = [-72.4, -60]
-    lat = [42, 46.1]
-
-    def test_dataset(self):
-        da = xr.open_mfdataset(
-            [self.nc_file, self.nc_file.replace("tasmax", "tasmin")],
-            combine="by_coords",
-        )
-        out = subset.subset_bbox(da, lon_bnds=self.lon, lat_bnds=self.lat)
-        assert np.all(out.lon >= np.min(self.lon))
-        assert np.all(out.lon <= np.max(self.lon))
-        assert np.all(out.lat >= np.min(self.lat))
-        assert np.all(out.lat <= np.max(self.lat))
-        np.testing.assert_array_equal(out.tasmin.shape, out.tasmax.shape)
-
-    def test_simple(self):
-        da = xr.open_dataset(self.nc_file).tasmax
-
-        out = subset.subset_bbox(da, lon_bnds=self.lon, lat_bnds=self.lat)
-        assert np.all(out.lon >= np.min(self.lon))
-        assert np.all(out.lon <= np.max(self.lon))
-        assert np.all(out.lat >= np.min(self.lat))
-        assert np.all(out.lat <= np.max(self.lat))
-
-        da = xr.open_dataset(self.nc_poslons).tas
-        da["lon"] -= 360
-        yr_st = "2050"
-        yr_ed = "2059"
-
-        out = subset.subset_bbox(
-            da, lon_bnds=self.lon, lat_bnds=self.lat, start_date=yr_st, end_date=yr_ed
-        )
-        assert np.all(out.lon >= np.min(self.lon))
-        assert np.all(out.lon <= np.max(self.lon))
-        assert np.all(out.lat >= np.min(self.lat))
-        assert np.all(out.lat <= np.max(self.lat))
-        np.testing.assert_array_equal(out.time.dt.year.max(), np.array(int(yr_ed)))
-        np.testing.assert_array_equal(out.time.dt.year.min(), np.array(int(yr_st)))
-
-        with pytest.warns(Warning):
-            out = subset.subset_bbox(
-                da, lon_bnds=self.lon, lat_bnds=self.lat, start_date=yr_st
-            )
-        assert np.all(out.lon >= np.min(self.lon))
-        assert np.all(out.lon <= np.max(self.lon))
-        assert np.all(out.lat >= np.min(self.lat))
-        assert np.all(out.lat <= np.max(self.lat))
-        np.testing.assert_array_equal(out.time.dt.year.max(), da.time.dt.year.max())
-        np.testing.assert_array_equal(out.time.dt.year.min(), np.array(int(yr_st)))
-
-        with pytest.warns(Warning):
-            out = subset.subset_bbox(
-                da, lon_bnds=self.lon, lat_bnds=self.lat, end_date=yr_ed
-            )
-        assert np.all(out.lon >= np.min(self.lon))
-        assert np.all(out.lon <= np.max(self.lon))
-        assert np.all(out.lat >= np.min(self.lat))
-        assert np.all(out.lat <= np.max(self.lat))
-        np.testing.assert_array_equal(out.time.dt.year.max(), np.array(int(yr_ed)))
-        np.testing.assert_array_equal(out.time.dt.year.min(), da.time.dt.year.min())
-
-    def test_irregular(self):
-        da = xr.open_dataset(self.nc_2dlonlat).tasmax
-
-        out = subset.subset_bbox(da, lon_bnds=self.lon, lat_bnds=self.lat)
-
-        # for irregular lat lon grids data matrix remains rectangular in native proj
-        # but with data outside bbox assigned nans.  This means it can have lon and lats outside the bbox.
-        # Check only non-nans gridcells using mask
-        mask1 = ~(np.isnan(out.sel(time=out.time[0])))
-
-        assert np.all(out.lon.values[mask1] >= np.min(self.lon))
-        assert np.all(out.lon.values[mask1] <= np.max(self.lon))
-        assert np.all(out.lat.values[mask1] >= np.min(self.lat))
-        assert np.all(out.lat.values[mask1] <= np.max(self.lat))
-
-    def test_positive_lons(self):
-        da = xr.open_dataset(self.nc_poslons).tas
-
-        out = subset.subset_bbox(da, lon_bnds=self.lon, lat_bnds=self.lat)
-        assert np.all(out.lon >= np.min(np.asarray(self.lon) + 360))
-        assert np.all(out.lon <= np.max(np.asarray(self.lon) + 360))
-        assert np.all(out.lat >= np.min(self.lat))
-        assert np.all(out.lat <= np.max(self.lat))
-
-        out = subset.subset_bbox(
-            da, lon_bnds=np.array(self.lon) + 360, lat_bnds=self.lat
-        )
-        assert np.all(out.lon >= np.min(np.asarray(self.lon) + 360))
-
-    def test_raise(self):
-        da = xr.open_dataset(self.nc_poslons).tas
-        with pytest.raises(ValueError):
-            with pytest.warns(Warning):
-                subset.subset_bbox(
-                    da, lon_bnds=self.lon, lat_bnds=self.lat, start_yr=2056, end_yr=2055
-                )
-
-        da = xr.open_dataset(self.nc_2dlonlat).tasmax.drop(["lon", "lat"])
-        with pytest.raises(Exception):
-            subset.subset_bbox(da, lon_bnds=self.lon, lat_bnds=self.lat)
-
-
 class TestThresholdCount:
     def test_simple(self, tas_series):
         ts = tas_series(np.arange(365))
@@ -763,3 +398,24 @@ class TestWindConversion:
             np.around(vas.values, decimals=10)
             == np.around(np.array([[1, 1], [-(np.hypot(1, 1)) / 3.6, -5]]), decimals=10)
         )
+
+
+def test_default_formatter():
+    assert default_formatter.format("{freq}", freq="YS") == "annual"
+    assert default_formatter.format("{freq:noun}", freq="MS") == "months"
+    assert default_formatter.format("{month}", month="m3") == "march"
+
+
+def test_AttrFormatter():
+    fmt = AttrFormatter(
+        mapping={"evil": ["méchant", "méchante"], "nice": ["beau", "belle"]},
+        modifiers=["m", "f"],
+    )
+    # Normal cases
+    assert fmt.format("{adj:m}", adj="evil") == "méchant"
+    assert fmt.format("{adj:f}", adj="nice") == "belle"
+    # Missing mod:
+    assert fmt.format("{adj}", adj="evil") == "méchant"
+    # Mod with unknown value
+    with pytest.raises(ValueError):
+        fmt.format("{adj:m}", adj="funny")