Repository: 12rambau/sepal_ui

Issue:
use box for the translator ? 
I discovered this lib while working on the geemap drop. 

I think it could be super handy for the translator keys and maybe faster. https://github.com/cdgriffith/Box 

side note: we will need it anyway for the geemap drop

Discussion:

Patch:
diff --git a/docs/source/modules/sepal_ui.translator.Translator.rst b/docs/source/modules/sepal_ui.translator.Translator.rst
index 642a3ab6..7f11e39f 100644
--- a/docs/source/modules/sepal_ui.translator.Translator.rst
+++ b/docs/source/modules/sepal_ui.translator.Translator.rst
@@ -2,19 +2,6 @@ sepal\_ui.translator.Translator
 ===============================
 
 .. autoclass:: sepal_ui.translator.Translator
-
-    .. rubric:: Attributes
-
-    .. autosummary::
-
-        ~Translator.default_dict
-        ~Translator.target_dict
-        ~Translator.default
-        ~Translator.target
-        ~Translator.targeted
-        ~Translator.match
-        ~Translator.keys
-        ~Translator.folder
    
     .. rubric:: Methods
 
@@ -33,7 +20,7 @@ sepal\_ui.translator.Translator
 
 .. automethod:: sepal_ui.translator.Translator.sanitize
 
-.. automethod:: sepal_ui.translator.Translator.search_key
+.. autofunction:: sepal_ui.translator.Translator.search_key
 
 .. automethod:: sepal_ui.translator.Translator.available_locales
 
diff --git a/sepal_ui/sepalwidgets/app.py b/sepal_ui/sepalwidgets/app.py
index 96c10461..bfd59e3d 100644
--- a/sepal_ui/sepalwidgets/app.py
+++ b/sepal_ui/sepalwidgets/app.py
@@ -602,7 +602,7 @@ class LocaleSelect(v.Menu, SepalWidget):
 
         # extract the language information from the translator
         # if not set default to english
-        code = "en" if translator is None else translator.target
+        code = "en" if translator is None else translator._target
         loc = self.COUNTRIES[self.COUNTRIES.code == code].squeeze()
         attr = {**self.ATTR, "src": self.FLAG.format(loc.flag), "alt": loc.name}
 
diff --git a/sepal_ui/translator/translator.py b/sepal_ui/translator/translator.py
index f4fdec47..efa29bc9 100644
--- a/sepal_ui/translator/translator.py
+++ b/sepal_ui/translator/translator.py
@@ -1,21 +1,26 @@
 import json
-from types import SimpleNamespace
 from pathlib import Path
 from collections import abc
-from deepdiff import DeepDiff
 from configparser import ConfigParser
 
-from deprecated.sphinx import versionadded
+from deprecated.sphinx import versionadded, deprecated
+from box import Box
 
 from sepal_ui import config_file
 
 
-class Translator(SimpleNamespace):
+class Translator(Box):
     """
-    The translator is a SimpleNamespace of Simplenamespace. It reads 2 Json files, the first one being the source language (usually English) and the second one the target language.
+    The translator is a Python Box of boxes. It reads 2 Json files, the first one being the source language (usually English) and the second one the target language.
     It will replace in the source dictionary every key that exist in both json dictionaries. Following this procedure, every message that is not translated can still be accessed in the source language.
     To access the dictionary keys, instead of using [], you can simply use key name as in an object ex: translator.first_key.secondary_key.
     There are no depth limits, just respect the snake_case convention when naming your keys in the .json files.
+    5 internal keys are created upon initialization (there name cannot be used as keys in the translation message):
+    -   (str) _default : the default locale of the translator
+    -   (str) _targeted : the initially requested language. Use to display debug information to the user agent
+    -   (str) _target : the target locale of the translator
+    -   (bool) _match : if the target language match the one requested one by user, used to trigger information in appBar
+    -   (str) _folder : the path to the l10n folder
 
     Args:
         json_folder (str | pathlib.Path): The folder where the dictionaries are stored
@@ -23,75 +28,60 @@ class Translator(SimpleNamespace):
         default (str, optional): The language code (IETF BCP 47) of the source lang. default to "en" (it should be the same as the source dictionary)
     """
 
-    FORBIDDEN_KEYS = [
-        "default_dict",
-        "target_dict",
-        "in",
-        "class",
-        "default",
-        "target",
-        "match",
-    ]
-    "list(str): list of the forbidden keys, using one of them in a translation dict will throw an error"
-
-    target_dict = {}
-    "(dict): the target language dictionary"
-
-    default_dict = {}
-    "dict: the source language dictionary"
-
-    default = None
-    "str: the default locale of the translator"
-
-    targeted = None
-    "str: the initially requested language. Use to display debug information to the user agent"
-
-    target = None
-    "str: the target locale of the translator"
-
-    match = None
-    "bool: if the target language match the one requested one by user, used to trigger information in appBar"
-
-    keys = None
-    "all the keys can be acceced as attributes"
-
-    folder = None
-    "pathlib.Path: the path to the l10n folder"
+    _protected_keys = [
+        "find_target",
+        "search_key",
+        "sanitize",
+        "_update",
+        "missing_keys",
+        "available_locales",
+        "merge_dict",
+        "delete_empty",
+    ] + dir(Box)
+    "keys that cannot be used as var names as they are protected for methods"
 
     def __init__(self, json_folder, target=None, default="en"):
 
-        # init the simple namespace
-        super().__init__()
+        # the name of the 5 variables that cannot be used as init keys
+        FORBIDDEN_KEYS = ["_folder", "_default", "_target", "_targeted", "_match"]
 
-        # force cast to path
-        self.folder = Path(json_folder)
+        # init the box with the folder
+        folder = Path(json_folder)
 
         # reading the default dict
-        self.default = default
-        self.default_dict = self.merge_dict(self.folder / default)
+        default_dict = self.merge_dict(folder / default)
 
         # create a dictionary in the target language
-        self.targeted, target = self.find_target(self.folder, target)
-        self.target = target or default
-        self.target_dict = self.merge_dict(self.folder / self.target)
+        targeted, target = self.find_target(folder, target)
+        target = target or default
+        target_dict = self.merge_dict(folder / target)
 
         # evaluate the matching of requested and obtained values
-        self.match = self.targeted == self.target
+        match = targeted == target
 
         # create the composite dictionary
-        ms_dict = self._update(self.default_dict, self.target_dict)
+        ms_dict = self._update(default_dict, target_dict)
 
         # check if forbidden keys are being used
-        [self.search_key(ms_dict, k) for k in self.FORBIDDEN_KEYS]
+        # this will raise an error if any
+        [self.search_key(ms_dict, k) for k in FORBIDDEN_KEYS]
 
-        # transform it into a json str
+        # # unpack the json as a simple namespace
         ms_json = json.dumps(ms_dict)
+        ms_boxes = json.loads(ms_json, object_hook=lambda d: Box(**d, frozen_box=True))
 
-        # unpack the json as a simple namespace
-        ms = json.loads(ms_json, object_hook=lambda d: SimpleNamespace(**d))
+        private_keys = {
+            "_folder": str(folder),
+            "_default": default,
+            "_targeted": targeted,
+            "_target": target,
+            "_match": match,
+        }
 
-        for k, v in ms.__dict__.items():
-            setattr(self, k, getattr(ms, k))
+        # the final box is not frozen
+        # waiting for an answer here: https://github.com/cdgriffith/Box/issues/223
+        # it the meantime it's easy to call the translator using a frozen_box argument
+        super(Box, self).__init__(**private_keys, **ms_boxes)
 
     @versionadded(version="2.7.0")
     @staticmethod
@@ -139,8 +129,8 @@ class Translator(SimpleNamespace):
 
         return (target, lang)
 
-    @classmethod
-    def search_key(cls, d, key):
+    @staticmethod
+    def search_key(d, key):
         """
         Search a specific key in the d dictionary and raise an error if found
 
@@ -149,14 +139,9 @@ class Translator(SimpleNamespace):
             key (str): the key to look for
         """
 
-        for k, v in d.items():
-            if isinstance(v, abc.Mapping):
-                cls.search_key(v, key)
-            else:
-                if k == key:
-                    raise Exception(
-                        f"You cannot use the key {key} in your translation dictionary"
-                    )
+        if key in d:
+            msg = f"You cannot use the key {key} in your translation dictionary"
+            raise Exception(msg)
 
         return
 
@@ -218,34 +203,19 @@ class Translator(SimpleNamespace):
 
         return ms
 
+    @deprecated(version="2.9.0", reason="Not needed with automatic translators")
     def missing_keys(self):
-        """
-        this function is intended for developer use only
-        print the list of the missing keys in the target dictionnairie
-
-        Return:
-            (str): the list of missing keys
-        """
-
-        # find all the missing keys
-        try:
-            ddiff = DeepDiff(self.default_dict, self.target_dict)[
-                "dictionary_item_removed"
-            ]
-        except Exception:
-            ddiff = ["All messages are translated"]
-
-        return "\n".join(ddiff)
+        pass
 
     def available_locales(self):
         """
         Return the available locales in the l10n folder
 
         Return:
-            (list): the lilst of str codes
+            (list): the list of str codes
         """
 
-        return [f.name for f in self.folder.iterdir() if f.is_dir()]
+        return [f.name for f in Path(self._folder).glob("[!^._]*") if f.is_dir()]
 
     @versionadded(version="2.7.0")
     @classmethod


Test patch:
diff --git a/tests/test_Translator.py b/tests/test_Translator.py
index c6d0e85e..ff6c46f8 100644
--- a/tests/test_Translator.py
+++ b/tests/test_Translator.py
@@ -35,9 +35,10 @@ class TestTranslator:
 
     def test_search_key(self):
 
-        # assert that having a wrong key in the json will raise an error
+        # assert that having a wrong key  at root level
+        # in the json will raise an error
         key = "toto"
-        d = {"a": {"toto": "b"}, "c": "d"}
+        d = {"toto": {"a": "b"}, "c": "d"}
 
         with pytest.raises(Exception):
             Translator.search_key(d, key)
@@ -72,16 +73,6 @@ class TestTranslator:
 
         assert Translator.delete_empty(test) == result
 
-    def test_missing_keys(self, translation_folder):
-
-        # check that all keys are in the fr dict
-        translator = Translator(translation_folder, "fr")
-        assert translator.missing_keys() == "All messages are translated"
-
-        # check that 1 key is missing
-        translator = Translator(translation_folder, "es")
-        assert translator.missing_keys() == "root['test_key']"
-
         return
 
     def test_find_target(self, translation_folder):
@@ -114,6 +105,12 @@ class TestTranslator:
         for locale in res:
             assert locale in translator.available_locales()
 
+        # Check no hidden and protected files are in locales
+        locales = translator.available_locales()
+        assert not all(
+            [(loc.startswith(".") or loc.startswith("_")) for loc in locales]
+        )
+
         return
 
     @pytest.fixture(scope="class")