Repository: wandb/wandb

Issue:
[CLI]: Cannot log pytorch model with artifacts
### Describe the bug

<!--- Description of the issue below  -->
I'm trying to log my PyTorch model with an artifact, but i keep getting this error
`wandb: ERROR Error while calling W&B API: json: cannot unmarshal string into Go value of type map[string]interface {} (<Response [500]>)`

While it is labeled as error it does not raise any Exception, so the program continues but nothing is logged after that.

<!--- A minimal code snippet between the quotes below  -->

This is my python script
```python 
  file_name =  "saved_models/model.pth"
  save_dict = {
        "accuracy": accuracy,
        "args": args,  # NameSpace from argparse
        "epoch": epoch,
        "model_state_dict": model.state_dict(),
        "optimizer_state_dict": optimizer.state_dict(),
    }

    torch.save(save_dict,file_name)

    artifact = wandb.Artifact("myModel", type="model", description="", metadata=args)
    artifact.add_file(file_name)
    self.run.log_artifact(artifact)
```

`self.run` is the ouptut of 
```
 self.run=wandb.init(
            group=group,
            entity="adaptive-speaker",
            id=run_id,
            dir=out_dir,
            config=opts,
            mode="disabled" if opts["debug"] else "online",
            #settings=wandb.Settings(start_method='fork'),
            **kwargs,
        )
```

I tried commenting out everything in the `save_dict`, but even with `save_dict={}` i still get the error.

I also tryed using `artifact.wait()`, but it just gets stuck while printing the error: 
<!--- A full traceback of the exception in the quotes below -->
```shell
wandb: ERROR Error while calling W&B API: json: cannot unmarshal string into Go value of type map[string]interface {} (<Response [500]>)

```


### Additional Files

_No response_

### Environment

WandB version:  0.12.16

OS: MacOS BigSur, version 11.6 (20G165)

Python version: 3.9.0

Versions of relevant libraries:
Torch: 1.11.0

### Additional Context

_No response_


Discussion:
I found the error. 
The metadata filed does not accept anything that is not a string. 
I don't know if I missed some doc where this was specified or not, but it would be useful to have either a warning or an implicit cast of the kind 
`metadata={k:str(v) for k,v in metadata.items()}`
Thanks for reporting the bug!  We'll ensure we sanitize this on the client side.
WandB Internal User commented: 
nicofirst1 commented: 
I found the error. 
The metadata filed does not accept anything that is not a string. 
I don't know if I missed some doc where this was specified or not, but it would be useful to have either a warning or an implicit cast of the kind 
`metadata={k:str(v) for k,v in metadata.items()}`

Hi @nicofirst1 ,

We have documented this bug and will update you when it is addressed. Thank-you!

Resolving patch:
diff --git a/wandb/sdk/wandb_artifacts.py b/wandb/sdk/wandb_artifacts.py
--- a/wandb/sdk/wandb_artifacts.py
+++ b/wandb/sdk/wandb_artifacts.py
@@ -1,6 +1,7 @@
 import base64
 import contextlib
 import hashlib
+import json
 import os
 import pathlib
 import re
@@ -9,6 +10,7 @@
 import time
 from typing import (
     Any,
+    cast,
     Dict,
     Generator,
     IO,
@@ -76,6 +78,14 @@ def __init__(self, entry: ArtifactEntry, obj: data_types.WBValue):
         self.obj = obj
 
 
+def _normalize_metadata(metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+    if metadata is None:
+        return {}
+    if not isinstance(metadata, dict):
+        raise TypeError(f"metadata must be dict, not {type(metadata)}")
+    return cast(Dict[str, Any], json.loads(json.dumps(metadata)))
+
+
 class Artifact(ArtifactInterface):
     """
     Flexible and lightweight building block for dataset and model versioning.
@@ -138,6 +148,7 @@ def __init__(
                 "Artifact name may only contain alphanumeric characters, dashes, underscores, and dots. "
                 'Invalid name: "%s"' % name
             )
+        metadata = _normalize_metadata(metadata)
         # TODO: this shouldn't be a property of the artifact. It's a more like an
         # argument to log_artifact.
         storage_layout = StorageLayout.V2
@@ -163,7 +174,7 @@ def __init__(
         self._type = type
         self._name = name
         self._description = description
-        self._metadata = metadata or {}
+        self._metadata = metadata
         self._distributed_id = None
         self._logged_artifact = None
         self._incremental = False
@@ -289,6 +300,7 @@ def metadata(self) -> dict:
 
     @metadata.setter
     def metadata(self, metadata: dict) -> None:
+        metadata = _normalize_metadata(metadata)
         if self._logged_artifact:
             self._logged_artifact.metadata = metadata
             return


Test patch:
diff --git a/tests/unit_tests/wandb_artifacts_test.py b/tests/unit_tests/wandb_artifacts_test.py
--- a/tests/unit_tests/wandb_artifacts_test.py
+++ b/tests/unit_tests/wandb_artifacts_test.py
@@ -1,6 +1,7 @@
 import base64
 import hashlib
 import os
+from typing import Any, Callable, Optional, Type
 import pytest
 from wandb import util
 import wandb
@@ -1335,7 +1336,7 @@ def test_lazy_artifact_passthrough(runner, live_mock_server, test_settings):
 
         for setter in testable_setters_valid + testable_setters_invalid:
             with pytest.raises(ValueError):
-                setattr(art, setter, "TEST")
+                setattr(art, setter, setter_data.get(setter, setter))
 
         for method in testable_methods_valid + testable_methods_invalid:
             attr_method = getattr(art, method)
@@ -1349,7 +1350,7 @@ def test_lazy_artifact_passthrough(runner, live_mock_server, test_settings):
             _ = getattr(art, getter)
 
         for setter in testable_setters_valid + testable_setters_invalid:
-            setattr(art, setter, "TEST")
+            setattr(art, setter, setter_data.get(setter, setter))
 
         for method in testable_methods_valid + testable_methods_invalid:
             attr_method = getattr(art, method)
@@ -1396,3 +1397,54 @@ def test_communicate_artifact(runner, publish_util, mocked_run):
         artifact_publish = dict(run=mocked_run, artifact=artifact, aliases=["latest"])
         ctx_util = publish_util(artifacts=[artifact_publish])
         assert len(set(ctx_util.manifests_created_ids)) == 1
+
+
+def _create_artifact_and_set_metadata(metadata):
+    artifact = wandb.Artifact("foo", "dataset")
+    artifact.metadata = metadata
+    return artifact
+
+
+# All these metadata-validation tests should behave identically
+# regardless of whether we set the metadata by passing it into the constructor
+# or by setting the attribute after creation; so, parametrize how we build the
+# artifact, and run tests both ways.
+@pytest.mark.parametrize(
+    "create_artifact",
+    [
+        lambda metadata: wandb.Artifact("foo", "dataset", metadata=metadata),
+        _create_artifact_and_set_metadata,
+    ],
+)
+class TestArtifactChecksMetadata:
+    def test_validates_metadata_ok(
+        self, create_artifact: Callable[..., wandb.Artifact]
+    ):
+        assert create_artifact(metadata=None).metadata == {}
+        assert create_artifact(metadata={"foo": "bar"}).metadata == {"foo": "bar"}
+
+    def test_validates_metadata_err(
+        self, create_artifact: Callable[..., wandb.Artifact]
+    ):
+        with pytest.raises(TypeError):
+            create_artifact(metadata=123)
+
+        with pytest.raises(TypeError):
+            create_artifact(metadata=[])
+
+        with pytest.raises(TypeError):
+            create_artifact(metadata={"unserializable": object()})
+
+    def test_deepcopies_metadata(self, create_artifact: Callable[..., wandb.Artifact]):
+        orig_metadata = {"foo": ["original"]}
+        artifact = create_artifact(metadata=orig_metadata)
+
+        # ensure `artifact.metadata` isn't just a reference to the argument
+        assert artifact.metadata is not orig_metadata
+        orig_metadata["bar"] = "modifying the top-level value"
+        assert "bar" not in artifact.metadata
+
+        # ensure that any mutable sub-values are also copies
+        assert artifact.metadata["foo"] is not orig_metadata["foo"]
+        orig_metadata["foo"].append("modifying the sub-value")
+        assert artifact.metadata["foo"] == ["original"]