Repository: napari/napari

Issue:
Deferred translation strings with kwargs cannot be deepcopied
## 🐛 Bug
#2913 fixed a bug in calling deepcopy on a TranslationString, but I just ran into a case where it fails when kwargs are provided and deferred is True:

```
deepcopy(trans._('Convert to {dtype}', dtype='uint16', deferred=True))
```

<details>

```pytb
File ~/mambaforge/envs/napari/lib/python3.10/copy.py:153, in deepcopy(x, memo, _nil)
    151 copier = getattr(x, "__deepcopy__", None)
    152 if copier is not None:
--> 153     y = copier(memo)
    154 else:
    155     reductor = dispatch_table.get(cls)

File ~/dev/self/napari/napari/utils/translations.py:170, in TranslationString.__deepcopy__(self, memo)
    167 def __deepcopy__(self, memo):
    168     from copy import deepcopy
--> 170     return TranslationString(
    171         domain=self._domain,
    172         msgctxt=self._msgctxt,
    173         msgid=self._msgid,
    174         msgid_plural=self._msgid_plural,
    175         n=self._n,
    176         deferred=self._deferred,
    177         kwargs=deepcopy(self._kwargs),
    178     )

File ~/dev/self/napari/napari/utils/translations.py:199, in TranslationString.__new__(cls, domain, msgctxt, msgid, msgid_plural, n, deferred, **kwargs)
    191     raise ValueError(
    192         trans._("Must provide at least a `msgid` parameter!")
    193     )
    195 kwargs["n"] = n
    197 return str.__new__(
    198     cls,
--> 199     cls._original_value(
    200         msgid,
    201         msgid_plural,
    202         n,
    203         kwargs,
    204     ),
    205 )

File ~/dev/self/napari/napari/utils/translations.py:256, in TranslationString._original_value(cls, msgid, msgid_plural, n, kwargs)
    241 """
    242 Return the original string with interpolated kwargs, if provided.
    243
   (...)
    253     Any additional arguments to use when formating the string.
    254 """
    255 string = msgid if n is None or n == 1 else msgid_plural
--> 256 return string.format(**kwargs)

```

</details>

cc @goanpeca, @Carreau 


Discussion:
Taking a look at it

Resolving patch:
diff --git a/napari/utils/translations.py b/napari/utils/translations.py
--- a/napari/utils/translations.py
+++ b/napari/utils/translations.py
@@ -167,6 +167,10 @@ class TranslationString(str):
     def __deepcopy__(self, memo):
         from copy import deepcopy
 
+        kwargs = deepcopy(self._kwargs)
+        # Remove `n` from `kwargs` added in the initializer
+        # See https://github.com/napari/napari/issues/4736
+        kwargs.pop("n")
         return TranslationString(
             domain=self._domain,
             msgctxt=self._msgctxt,
@@ -174,7 +178,7 @@ def __deepcopy__(self, memo):
             msgid_plural=self._msgid_plural,
             n=self._n,
             deferred=self._deferred,
-            kwargs=deepcopy(self._kwargs),
+            **kwargs,
         )
 
     def __new__(


Test patch:
diff --git a/napari/utils/_tests/test_translations.py b/napari/utils/_tests/test_translations.py
--- a/napari/utils/_tests/test_translations.py
+++ b/napari/utils/_tests/test_translations.py
@@ -379,13 +379,48 @@ def test_bundle_exceptions(trans):
         trans._dnpgettext()
 
 
-def test_deepcopy():
-    """see https://github.com/napari/napari/issues/2911
+@pytest.mark.parametrize(
+    "kwargs",
+    [
+        {
+            'msgid': 'huhu',
+        },
+        {
+            'msgid': 'Convert to {dtype}',
+            'dtype': 'uint16',
+        },
+        {
+            'msgid': 'Convert to {dtype}',
+            'dtype': 'uint16',
+            'deferred': True,
+        },
+        {
+            'msgid': 'Convert to {dtype}',
+            'dtype': 'uint16',
+            'deferred': False,
+        },
+        {
+            'msgid': 'Convert to {dtype}',
+            'msgid_plural': 'Convert to {dtype}s',
+            'n': 1,
+            'dtype': 'uint16',
+        },
+        {
+            'msgid': 'Convert to {dtype}',
+            'msgid_plural': 'Convert to {dtype}s',
+            'n': 2,
+            'dtype': 'uint16',
+        },
+    ],
+)
+def test_deepcopy(kwargs):
+    """Object containing translation strings can't be deep-copied.
 
-    Object containing translation strings can't bee deep-copied.
+    See:
+      - https://github.com/napari/napari/issues/2911
+      - https://github.com/napari/napari/issues/4736
     """
-
-    t = TranslationString(msgid='huhu')
+    t = TranslationString(**kwargs)
     u = deepcopy(t)
     assert t is not u
     assert t == u