Repository: pantsbuild/pants

Issue:
Option registration should fail if the default value is of the wrong type.
I ran into an issue where we had an option that didn't specify type, but expected it to be bool and had a default of False. When I updated, the default value was coerced into a string and became truthy. I think option registration should blow up if the option default isn't the right type.



Discussion:
On it.

I've looked into this a little bit, as it sounded quite easy to address. Was a little bit more involved than expected, so just want to check if this is something that's still desirable to fix, or if the case has gone stale enough to be irrelevant.. ?

(not sure if the default value even is coerced into strings any more...)

((also, isn't the options part of pants being ported over to the rust side of things, so perhaps the whole python implementation will go away in the foreseeable future.. ?))

I think it would still be great to fix.

Although warning that our options code is a bit hairy, including the tests. Would you be interested in refactoring some of the tests as prework to make our options code easier to hack on? Building off of https://github.com/pantsbuild/pants/pull/12010 to port some more of `options_test.py` to Pytest. No worries if not - I personally find refactoring really fun and wish I had more time for it hah, but I know it can also be seen as tedius.

> isn't the options part of pants being ported over to the rust side of things

Iiuc, John has only been porting part of it so that we can parse the "bootstrap" options (like `--pantsd`) so that we can have a native client, which greatly reduces overhead time for Pants.
OK, then I'll proceed.
I have a working POC that works for simple cases, but that blows up in other parts, mostly due to default values being given as string representations for structured data, for instance.

e.g. from one of the tests:
```
E       pants.option.errors.DefaultValueType: Default value '[1, 2, 3]' does not match option type list. [option --listy in global scope].
```

So, looking at how best to handle those.. (did implement a check that runs `to_value_type` on the default value to cover this case, but I'll have to dig deeper to see why it didn't work here..)

I don't mind hairy code (as it is rather well contained, so it's not too hard to follow ;) )


Resolving patch:
diff --git a/src/python/pants/option/errors.py b/src/python/pants/option/errors.py
--- a/src/python/pants/option/errors.py
+++ b/src/python/pants/option/errors.py
@@ -33,6 +33,17 @@ class BooleanOptionNameWithNo(RegistrationError):
     """Boolean option names cannot start with --no."""
 
 
+class DefaultValueType(RegistrationError):
+    """Default value {value_type}({default_value!r}) does not match option type {option_type}."""
+
+
+class DefaultMemberValueType(DefaultValueType):
+    """Default member value type mismatch.
+
+    Member value {value_type}({member_value!r}) does not match list option type {member_type}.
+    """
+
+
 class ImplicitValIsNone(RegistrationError):
     """Implicit value cannot be None."""
 
diff --git a/src/python/pants/option/global_options.py b/src/python/pants/option/global_options.py
--- a/src/python/pants/option/global_options.py
+++ b/src/python/pants/option/global_options.py
@@ -1399,7 +1399,7 @@ def register_options(cls, register):
         register(
             "--streaming-workunits-report-interval",
             type=float,
-            default=1,
+            default=1.0,
             advanced=True,
             help="Interval in seconds between when streaming workunit event receivers will be polled.",
         )
diff --git a/src/python/pants/option/parser.py b/src/python/pants/option/parser.py
--- a/src/python/pants/option/parser.py
+++ b/src/python/pants/option/parser.py
@@ -32,6 +32,8 @@
 from pants.option.errors import (
     BooleanConversionError,
     BooleanOptionNameWithNo,
+    DefaultMemberValueType,
+    DefaultValueType,
     FromfileError,
     ImplicitValIsNone,
     InvalidKwarg,
@@ -502,6 +504,41 @@ def error(
         if not is_enum and member_type not in self._allowed_member_types:
             error(InvalidMemberType, member_type=member_type.__name__)
 
+        # check type of default value
+        default_value = kwargs.get("default")
+        if default_value is not None:
+            if isinstance(default_value, str) and type_arg != str:
+                # attempt to parse default value, for correctness..
+                # custom function types may implement their own validation
+                default_value = self.to_value_type(default_value, type_arg, member_type, "")
+                if hasattr(default_value, "val"):
+                    default_value = default_value.val
+
+                # fall through to type check, to verify that custom types returned a value of correct type
+
+            if isinstance(type_arg, type) and not isinstance(default_value, type_arg):
+                error(
+                    DefaultValueType,
+                    option_type=type_arg.__name__,
+                    default_value=kwargs["default"],
+                    value_type=type(default_value).__name__,
+                )
+
+            # verify list member types (this is not done by the custom list value type)
+            if type_arg == list:
+                for member_val in default_value:
+                    if not isinstance(member_type, type):
+                        # defer value validation to custom type
+                        member_type(member_val)
+
+                    elif not isinstance(member_val, member_type):
+                        error(
+                            DefaultMemberValueType,
+                            member_type=member_type.__name__,
+                            member_value=member_val,
+                            value_type=type(member_val).__name__,
+                        )
+
         if (
             "passthrough" in kwargs
             and kwargs["passthrough"]


Test patch:
diff --git a/src/python/pants/option/options_test.py b/src/python/pants/option/options_test.py
--- a/src/python/pants/option/options_test.py
+++ b/src/python/pants/option/options_test.py
@@ -7,6 +7,7 @@
 import os
 import shlex
 import unittest.mock
+from contextlib import contextmanager
 from enum import Enum
 from functools import partial
 from textwrap import dedent
@@ -25,6 +26,7 @@
 from pants.option.errors import (
     BooleanConversionError,
     BooleanOptionNameWithNo,
+    DefaultValueType,
     FromfileError,
     ImplicitValIsNone,
     InvalidKwarg,
@@ -224,6 +226,91 @@ def register(opts: Options) -> None:
         create_options([GLOBAL_SCOPE], register, config={"GLOBAL": {"opt": val}}).for_global_scope()
 
 
+# ----------------------------------------------------------------------------------------
+# Type checks
+# ----------------------------------------------------------------------------------------
+
+
+@contextmanager
+def no_exception():
+    """use in tests as placeholder for a pytest.raises, when no exception is expected."""
+    yield None
+
+
+@pytest.mark.parametrize(
+    "option_kwargs, assert_expected",
+    [
+        (
+            dict(type=str, default=""),
+            no_exception(),
+        ),
+        (
+            dict(type=str, default=42),
+            pytest.raises(
+                DefaultValueType, match=r"Default value int\(42\) does not match option type str\."
+            ),
+        ),
+        (
+            dict(type=bool, default="True"),
+            no_exception(),
+        ),
+        (
+            dict(type=bool, default=True),
+            no_exception(),
+        ),
+        (
+            dict(type=bool, default="not a bool"),
+            pytest.raises(
+                BooleanConversionError, match=r'Got "not a bool"\. Expected "True" or "False"\.'
+            ),
+        ),
+        (
+            dict(type=int, default=1.0),
+            pytest.raises(
+                DefaultValueType,
+                match=r"Default value float\(1\.0\) does not match option type int\. \[option --opt in global scope\]\.",
+            ),
+        ),
+        (
+            dict(type=list, member_type=int, default="[1, 2, 3]"),
+            no_exception(),
+        ),
+        (
+            dict(type=list, member_type=int, default="[1, 2.1, 3]"),
+            pytest.raises(
+                DefaultValueType,
+                match=r"Default member value type mismatch\.\n\n    Member value float\(2\.1\) does not match list option type int\.",
+            ),
+        ),
+        (
+            dict(type=list, member_type=float, default="[1.1, 2.0, 3.3]"),
+            no_exception(),
+        ),
+        (
+            dict(type=list, member_type=float, default="[1.1, 2.2, '3.3']"),
+            pytest.raises(
+                DefaultValueType,
+                match=r"Member value str\('3\.3'\) does not match list option type float\.",
+            ),
+        ),
+        (
+            dict(type=dict, default="{'foo': 'bar'}"),
+            no_exception(),
+        ),
+        (
+            dict(type=dict, default="['foo', 'bar']"),
+            pytest.raises(ParseError, match=r"Invalid dict value: \['foo', 'bar'\]"),
+        ),
+    ],
+)
+def test_default_value_type_assert(option_kwargs, assert_expected):
+    def register(opts: Options) -> None:
+        opts.register(GLOBAL_SCOPE, "--opt", **option_kwargs)
+
+    with assert_expected:
+        create_options([GLOBAL_SCOPE], register).for_scope(GLOBAL_SCOPE)
+
+
 # ----------------------------------------------------------------------------------------
 # Deprecations.
 # ----------------------------------------------------------------------------------------