Repository: conan-io/conan

Issue:
Allow specifying default options as a dictionary
To help us debug your issue please explain:

- [x] I've read the [CONTRIBUTING guide](https://raw.githubusercontent.com/conan-io/conan/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.

Hello,

Looking at the structure of the conanfile.py, I always found it weird why the options were specified as a dictionary, but the default_options as a list of tuples.
This is not an issue for packages that have a small amount of options, but it gets ugly for projects with a lot of options. I always end up doing the following:
```python
_default_options = {
    "shared": True,
    "tools": True,
    ...
}
default_options = [(k, str(v)) for k, v in _default_options.items()]
```
In my opinion, it would be a great enhancement if the default_options would allow specifying values as a dictionary and would make the style of the conanfile.py more uniform.

Software information:
I'm currently using ```Conan version 1.2.0``` on
```
ProductName:    Mac OS X
ProductVersion: 10.13.3
BuildVersion:   17D102
```
with
```
Xcode 9.2
Build version 9C40b
```
and ```Python 3.6.4``` installed from brew


Discussion:
Well, you can actually define default options as a list, tuple or multiline string:

Have a look here: https://github.com/conan-io/conan/blob/777f846df4cabe366ddcb88e39f6c7cd8970d7e1/conans/model/conan_file.py#L22

I think it was done that way to avoid the possibility of declaring more than one value as default and to show a kind of "fixed" value in the recipe.
Yes, this might be possible and I don't see any strong reason not to allow it.
@danimtb thanks for the quick reply.

The question was partly inspired when I looked at that exact line and saw that there is no ```isinstance``` case for dict, seeing as how it's a one-liner to convert it to a list of tuples.

@memsharded In my opinion this would be a great enhancement to the ConanFile

Resolving patch:
diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py
--- a/conans/model/conan_file.py
+++ b/conans/model/conan_file.py
@@ -22,13 +22,13 @@ def create_options(conanfile):
 
         default_options = getattr(conanfile, "default_options", None)
         if default_options:
-            if isinstance(default_options, (list, tuple)):
+            if isinstance(default_options, (list, tuple, dict)):
                 default_values = OptionsValues(default_options)
             elif isinstance(default_options, str):
                 default_values = OptionsValues.loads(default_options)
             else:
-                raise ConanException("Please define your default_options as list or "
-                                     "multiline string")
+                raise ConanException("Please define your default_options as list, "
+                                     "multiline string or dictionary")
             options.values = default_values
         return options
     except Exception as e:
diff --git a/conans/model/options.py b/conans/model/options.py
--- a/conans/model/options.py
+++ b/conans/model/options.py
@@ -1,8 +1,11 @@
-from conans.util.sha import sha1
-from conans.errors import ConanException
+
 import yaml
 import six
 import fnmatch
+from collections import Counter
+
+from conans.util.sha import sha1
+from conans.errors import ConanException
 
 
 _falsey_options = ["false", "none", "0", "off", ""]
@@ -162,19 +165,20 @@ def __init__(self, values=None):
 
         # convert tuple "Pkg:option=value", "..." to list of tuples(name, value)
         if isinstance(values, tuple):
-            new_values = []
-            for v in values:
-                option, value = v.split("=", 1)
-                new_values.append((option.strip(), value.strip()))
-            values = new_values
+            values = [item.split("=", 1) for item in values]
+
+        # convert dict {"Pkg:option": "value", "..": "..", ...} to list of tuples (name, value)
+        if isinstance(values, dict):
+            values = [(k, v) for k, v in values.items()]
 
         # handle list of tuples (name, value)
         for (k, v) in values:
+            k = k.strip()
+            v = v.strip() if isinstance(v, six.string_types) else v
             tokens = k.split(":")
             if len(tokens) == 2:
                 package, option = tokens
-                package_values = self._reqs_options.setdefault(package.strip(),
-                                                               PackageOptionValues())
+                package_values = self._reqs_options.setdefault(package.strip(), PackageOptionValues())
                 package_values.add_option(option, v)
             else:
                 self._package_values.add_option(k, v)
@@ -264,14 +268,8 @@ def loads(text):
         other_option=3
         OtherPack:opt3=12.1
         """
-        result = []
-        for line in text.splitlines():
-            line = line.strip()
-            if not line:
-                continue
-            name, value = line.split("=", 1)
-            result.append((name.strip(), value.strip()))
-        return OptionsValues(result)
+        options = tuple(line.strip() for line in text.splitlines() if line.strip())
+        return OptionsValues(options)
 
     @property
     def sha(self):


Test patch:
diff --git a/conans/test/integration/options_test.py b/conans/test/integration/options_test.py
--- a/conans/test/integration/options_test.py
+++ b/conans/test/integration/options_test.py
@@ -65,6 +65,73 @@ class ConanLib(ConanFile):
         conaninfo = load(os.path.join(client.current_folder, CONANINFO))
         self.assertNotIn("zlib:shared=True", conaninfo)
 
+    def test_default_options(self):
+        client = TestClient()
+        conanfile = """
+from conans import ConanFile
+
+class MyConanFile(ConanFile):
+    name = "MyConanFile"
+    version = "1.0"
+    options = {"config": %s}
+    default_options = "config%s"
+
+    def configure(self):
+        if self.options.config:
+            self.output.info("Boolean evaluation")
+        if self.options.config is None:
+            self.output.info("None evaluation")
+        if self.options.config == "None":
+            self.output.info("String evaluation")
+"""
+        # Using "ANY" as possible options
+        client.save({"conanfile.py": conanfile % ("\"ANY\"", "")})
+        error = client.run("create . danimtb/testing", ignore_error=True)
+        self.assertTrue(error)
+        self.assertIn("Error while initializing options.", client.out)
+        client.save({"conanfile.py": conanfile % ("\"ANY\"", "=None")})
+        client.run("create . danimtb/testing")
+        self.assertNotIn("Boolean evaluation", client.out)
+        self.assertNotIn("None evaluation", client.out)
+        self.assertIn("String evaluation", client.out)
+
+        # Using None as possible options
+        client.save({"conanfile.py": conanfile % ("[None]", "")})
+        error = client.run("create . danimtb/testing", ignore_error=True)
+        self.assertTrue(error)
+        self.assertIn("Error while initializing options.", client.out)
+        client.save({"conanfile.py": conanfile % ("[None]", "=None")})
+        client.run("create . danimtb/testing")
+        self.assertNotIn("Boolean evaluation", client.out)
+        self.assertNotIn("None evaluation", client.out)
+        self.assertIn("String evaluation", client.out)
+
+        # Using "None" as possible options
+        client.save({"conanfile.py": conanfile % ("[\"None\"]", "")})
+        error = client.run("create . danimtb/testing", ignore_error=True)
+        self.assertTrue(error)
+        self.assertIn("Error while initializing options.", client.out)
+        client.save({"conanfile.py": conanfile % ("[\"None\"]", "=None")})
+        client.run("create . danimtb/testing")
+        self.assertNotIn("Boolean evaluation", client.out)
+        self.assertNotIn("None evaluation", client.out)
+        self.assertIn("String evaluation", client.out)
+        client.save({"conanfile.py": conanfile % ("[\"None\"]", "=\\\"None\\\"")})
+        error = client.run("create . danimtb/testing", ignore_error=True)
+        self.assertTrue(error)
+        self.assertIn("'\"None\"' is not a valid 'options.config' value", client.out)
+
+        # Using "ANY" as possible options and "otherstringvalue" as default
+        client.save({"conanfile.py": conanfile % ("[\"otherstringvalue\"]", "")})
+        error = client.run("create . danimtb/testing", ignore_error=True)
+        self.assertTrue(error)
+        self.assertIn("Error while initializing options.", client.out)
+        client.save({"conanfile.py": conanfile % ("\"ANY\"", "=otherstringvalue")})
+        client.run("create . danimtb/testing")
+        self.assertIn("Boolean evaluation", client.out)
+        self.assertNotIn("None evaluation", client.out)
+        self.assertNotIn("String evaluation", client.out)
+
     def general_scope_options_test(self):
         # https://github.com/conan-io/conan/issues/2538
         client = TestClient()
diff --git a/conans/test/model/options_test.py b/conans/test/model/options_test.py
--- a/conans/test/model/options_test.py
+++ b/conans/test/model/options_test.py
@@ -1,3 +1,4 @@
+import six
 import unittest
 from conans.model.options import OptionsValues, PackageOptions, Options, PackageOptionValues,\
     option_undefined_msg
@@ -239,6 +240,39 @@ def test_from_list(self):
         option_values = OptionsValues(self.sut.as_list())
         self.assertEqual(option_values.dumps(), self.sut.dumps())
 
+    def test_from_dict(self):
+        options_as_dict = dict([item.split('=') for item in self.sut.dumps().splitlines()])
+        option_values = OptionsValues(options_as_dict)
+        self.assertEqual(option_values.dumps(), self.sut.dumps())
+
+    def test_consistency(self):
+        def _check_equal(hs1, hs2, hs3, hs4):
+            opt_values1 = OptionsValues(hs1)
+            opt_values2 = OptionsValues(hs2)
+            opt_values3 = OptionsValues(hs3)
+            opt_values4 = OptionsValues(hs4)
+
+            self.assertEqual(opt_values1.dumps(), opt_values2.dumps())
+            self.assertEqual(opt_values1.dumps(), opt_values3.dumps())
+            self.assertEqual(opt_values1.dumps(), opt_values4.dumps())
+
+        # Check that all possible input options give the same result
+        _check_equal([('opt', 3)],       [('opt', '3'), ],       ('opt=3', ),       {'opt': 3})
+        _check_equal([('opt', True)],    [('opt', 'True'), ],    ('opt=True', ),    {'opt': True})
+        _check_equal([('opt', False)],   [('opt', 'False'), ],   ('opt=False', ),   {'opt': False})
+        _check_equal([('opt', None)],    [('opt', 'None'), ],    ('opt=None', ),    {'opt': None})
+        _check_equal([('opt', 0)],       [('opt', '0'), ],       ('opt=0', ),       {'opt': 0})
+        _check_equal([('opt', '')],      [('opt', ''), ],        ('opt=', ),        {'opt': ''})
+
+        # Check for leading and trailing spaces
+        _check_equal([('  opt  ', 3)], [(' opt  ', '3'), ], ('  opt =3', ), {' opt ': 3})
+        _check_equal([('opt', '  value  ')], [('opt', '  value '), ], ('opt= value  ', ),
+                     {'opt': ' value '})
+
+        # This is expected behaviour:
+        self.assertNotEqual(OptionsValues([('opt', ''), ]).dumps(),
+                            OptionsValues(('opt=""', )).dumps())
+
     def test_dumps(self):
         self.assertEqual(self.sut.dumps(), "\n".join(["optimized=3",
                                                       "static=True",
@@ -265,3 +299,34 @@ def test_sha_constant(self):
                                                       "Poco:new_option=0"]))
         self.assertEqual(self.sut.sha,
                          "2442d43f1d558621069a15ff5968535f818939b5")
+
+    def test_loads_exceptions(self):
+        emsg = "not enough values to unpack" if six.PY3 else "need more than 1 value to unpack"
+        with self.assertRaisesRegexp(ValueError, emsg):
+            OptionsValues.loads("a=2\nconfig\nb=3")
+
+        with self.assertRaisesRegexp(ValueError, emsg):
+            OptionsValues.loads("config\na=2\ncommit\nb=3")
+
+    def test_exceptions_empty_value(self):
+        emsg = "not enough values to unpack" if six.PY3 else "need more than 1 value to unpack"
+        with self.assertRaisesRegexp(ValueError, emsg):
+            OptionsValues("a=2\nconfig\nb=3")
+
+        with self.assertRaisesRegexp(ValueError, emsg):
+            OptionsValues(("a=2", "config"))
+
+        with self.assertRaisesRegexp(ValueError, emsg):
+            OptionsValues([('a', 2), ('config', ), ])
+
+    def test_exceptions_repeated_value(self):
+        try:
+            OptionsValues.loads("a=2\na=12\nb=3").dumps()
+            OptionsValues(("a=2", "b=23", "a=12"))
+            OptionsValues([('a', 2), ('b', True), ('a', '12')])
+        except Exception as e:
+            self.fail("Not expected exception: {}".format(e))
+
+    def test_package_with_spaces(self):
+        self.assertEqual(OptionsValues([('pck2:opt', 50), ]).dumps(),
+                         OptionsValues([('pck2 :opt', 50), ]).dumps())
diff --git a/conans/test/model/transitive_reqs_test.py b/conans/test/model/transitive_reqs_test.py
--- a/conans/test/model/transitive_reqs_test.py
+++ b/conans/test/model/transitive_reqs_test.py
@@ -560,6 +560,17 @@ class HelloConan(ConanFile):
 """
         _assert_conanfile(hello_content_tuple)
 
+        hello_content_dict = """
+from conans import ConanFile
+
+class HelloConan(ConanFile):
+    name = "Hello"
+    version = "1.2"
+    requires = "Say/0.1@user/testing"
+    default_options = {"Say:myoption" : 234, }  # To test dict definition
+"""
+        _assert_conanfile(hello_content_dict)
+
     def test_transitive_two_levels_options(self):
         say_content = """
 from conans import ConanFile