Repository: napari/napari

Issue:
`schema_version` should be typed and preferably serialized to string
Everything in our settings models should have type annotations.  
Currently, all of the `schema_version` fields are untyped tuples of ints:
```python
class AppearanceSettings(BaseNapariSettings):
    schema_version = (0, 1, 0)
```
this means they serialize to our settings like this:
```yaml
appearance:
  schema_version:
  - 0
  - 1
  - 0
  theme: dark
```

which is a little hard to visually parse.  Since it's used in every submodel, it would be nice to have a `SchemaVersion` field that handles ser/des so that can be:
```yaml
appearance:
  schema_version: 0.1.0
  theme: dark
```



Discussion:
Absolutely :) 

Great idea @tlambert03 !

---

Why do you keep looking at raw yaml files :-p ? (jk 😆 )
:joy:  I know I know...
if you're going to take this one, can you add a test to check that every field in each model in `CORE_SETTINGS` has a type annotation? 
>if you're going to take this one, can you add a test to check that every field in each model in CORE_SETTINGS has a type annotation?

Sure 👀 

Resolving patch:
diff --git a/napari/utils/settings/_defaults.py b/napari/utils/settings/_defaults.py
--- a/napari/utils/settings/_defaults.py
+++ b/napari/utils/settings/_defaults.py
@@ -14,6 +14,75 @@
 from ..translations import _load_language, get_language_packs, trans
 
 
+class SchemaVersion(str):
+    """
+    Custom schema version type to handle both tuples and version strings.
+
+    Provides also a `as_tuple` method for convenience when doing version
+    comparison.
+    """
+
+    def __new__(cls, value):
+        if isinstance(value, (tuple, list)):
+            value = ".".join(str(item) for item in value)
+
+        return str.__new__(cls, value)
+
+    def __init__(self, value):
+        if isinstance(value, (tuple, list)):
+            value = ".".join(str(item) for item in value)
+
+        self._value = value
+
+    @classmethod
+    def __get_validators__(cls):
+        yield cls.validate
+
+    @classmethod
+    def validate(cls, v):
+        if isinstance(v, (tuple, list)):
+            v = ".".join(str(item) for item in v)
+
+        if not isinstance(v, str):
+            raise ValueError(
+                trans._(
+                    "A schema version must be a 3 element tuple or string!"
+                ),
+                deferred=True,
+            )
+
+        parts = v.split(".")
+        if len(parts) != 3:
+            raise ValueError(
+                trans._(
+                    "A schema version must be a 3 element tuple or string!"
+                ),
+                deferred=True,
+            )
+
+        for part in parts:
+            try:
+                int(part)
+            except Exception:
+                raise ValueError(
+                    trans._(
+                        "A schema version subparts must be positive integers or parseable as integers!"
+                    ),
+                    deferred=True,
+                )
+
+        return cls(v)
+
+    def __repr__(self):
+        return f'SchemaVersion("{self._value}")'
+
+    def __str__(self):
+        return f'"{self._value}"'
+
+    def as_tuple(self):
+        return tuple(int(p) for p in self._value.split('.'))
+
+
 class Theme(str):
     """
     Custom theme type to dynamically load all installed themes.
@@ -113,7 +182,7 @@ class AppearanceSettings(BaseNapariSettings):
     #    version, e.g. from 3.0.0 to 4.0.0
     # 3. You don't need to touch this value if you're just adding a new option
 
-    schema_version = (0, 1, 0)
+    schema_version: SchemaVersion = (0, 1, 0)
 
     theme: Theme = Field(
         "dark",
@@ -140,7 +209,7 @@ class ApplicationSettings(BaseNapariSettings):
     #    version, e.g. from 3.0.0 to 4.0.0
     # 3. You don't need to touch this value if you're just adding a new option
 
-    schema_version = (0, 1, 0)
+    schema_version: SchemaVersion = (0, 1, 0)
 
     first_time: bool = True
 
@@ -202,8 +271,7 @@ class NapariConfig:
 class PluginsSettings(BaseNapariSettings):
     """Plugins Settings."""
 
-    schema_version = (0, 1, 0)
-
+    schema_version: SchemaVersion = (0, 1, 1)
     call_order: CallOrderDict = Field(
         None,
         title=trans._("Plugin sort order"),
diff --git a/napari/utils/settings/_manager.py b/napari/utils/settings/_manager.py
--- a/napari/utils/settings/_manager.py
+++ b/napari/utils/settings/_manager.py
@@ -1,6 +1,7 @@
 """Settings management.
 """
 
+import json
 import os
 from pathlib import Path
 
@@ -83,7 +84,7 @@ def __dir__(self):
         return super().__dir__() + list(self._settings)
 
     def __str__(self):
-        return safe_dump(self._to_dict())
+        return safe_dump(self._to_dict(safe=True))
 
     @staticmethod
     def _get_section_name(setting) -> str:
@@ -92,11 +93,16 @@ def _get_section_name(setting) -> str:
         """
         return setting.__name__.replace("Settings", "").lower()
 
-    def _to_dict(self) -> dict:
+    def _to_dict(self, safe: bool = False) -> dict:
         """Convert the settings to a dictionary."""
         data = {}
         for section, model in self._settings.items():
-            data[section] = model.dict()
+            if safe:
+                # We roundtrip to keep string objects (like SchemaVersion)
+                # yaml representable
+                data[section] = json.loads(model.json())
+            else:
+                data[section] = model.dict()
 
         return data
 


Test patch:
diff --git a/napari/utils/settings/_tests/test_settings.py b/napari/utils/settings/_tests/test_settings.py
--- a/napari/utils/settings/_tests/test_settings.py
+++ b/napari/utils/settings/_tests/test_settings.py
@@ -4,7 +4,7 @@
 import pydantic
 import pytest
 
-from napari.utils.settings._manager import SettingsManager
+from napari.utils.settings._manager import CORE_SETTINGS, SettingsManager
 from napari.utils.theme import get_theme, register_theme
 
 
@@ -148,3 +148,17 @@ def test_settings_load_invalid_content(tmp_path):
 
     with pytest.warns(UserWarning):
         SettingsManager(tmp_path)
+
+
+def test_model_fields_are_annotated():
+    errors = []
+    for model in CORE_SETTINGS:
+        difference = set(model.__fields__) - set(model.__annotations__)
+        if difference:
+            errors.append(
+                f"Model '{model.__name__}' does not provide annotations "
+                f"for the fields:\n{', '.join(repr(f) for f in difference)}"
+            )
+
+    if errors:
+        raise ValueError("\n\n".join(errors))