Repository: conan-io/conan
Pull request: implementing __contains__ for option in self.info.options
Changelog: Fix: Implement missing ``__contains__`` method, so checking ``if "myoption" in self.info.options`` is possible in ``package_id()``.
Docs: Omit

Fix https://github.com/conan-io/conan/issues/7299




Problem statement:
[bug] Infinite recursion when checking for an option in the package_id method
Thank you for all the great work in conan!  I was working on a base class that would invoke `shared_library_package_id` conditionally if "shared" was in the options.  (A try-catch would work for this case, but I was working through some ideas.)  I found that the `in` lookup was causing infinite stack recursion.

```python
def package_id(self):
    if "not_an_option" in self.info.options:
        self.info.shared_library_package_id()
```

### Environment Details (include every applicable attribute)
  * Operating System+version: Ubuntu 16.04.5
  * Conan version: Conan version 1.27.0
  * Python version: Python 3.5.2

Also tested in the docker:
  * Python version: Python 3.7.5
  * Conan versions: 1.0.0, 1.10.0, 1.27.0
  * Docker container:  `conanio/gcc5`

### Steps to reproduce (Include if Applicable)

1. Create a new conanfile:
   ```bash
   conan new test/1.0@user/testing
   ```
1. Append a `package_id` method which looks for an option that doesn't exist:
   ```bash
   printf "    def package_id(self):\n        'not_an_option' in self.info.options" >> conanfile.py
   ```
1. Attempt to create a package:
   ```bash
   conan create . user/testing
   ```

**Expected:** Builds 
**Actual:** 100% CPU and eventually 100% RAM

```
top - 22:12:09 up  6:26,  0 users,  load average: 0.89, 0.55, 0.41
Tasks:   4 total,   2 running,   2 sleeping,   0 stopped,   0 zombie
%Cpu(s): 13.2 us,  0.8 sy,  0.0 ni, 86.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem : 16281688 total,  1775688 free, 10938668 used,  3567332 buff/cache
KiB Swap: 33248252 total, 30537368 free,  2710884 used.  4498560 avail Mem 

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND     
  630 conan     20   0 1727356 1.554g   9544 R  99.7 10.0   0:11.36 conan       
    1 conan     20   0   18272   3408   2896 S   0.0  0.0   0:00.31 bash        
  313 conan     20   0   18272   3372   2864 S   0.0  0.0   0:00.28 bash        
  629 conan     20   0   36640   3156   2732 R   0.0  0.0   0:00.00 top         
```


### Logs (Executed commands with output) (Include/Attach if Applicable)

When running in pdb, I added a breakpoint at `conans/model/options.py:216` (line number for version 1.27.0), which led to an infinite loop there:

```
(Pdb) n
--Return--
> /home/rdesmond/.virtualenvs/conan-1.26.0/lib/python3.5/site-packages/conans/model/options.py(216)__getitem__()-><conans.model...x7f5c503ff5c0>
-> return self._reqs_options.setdefault(item, PackageOptionValues())
(Pdb) n
--Call--
> /home/rdesmond/.virtualenvs/conan-1.26.0/lib/python3.5/site-packages/conans/model/options.py(215)__getitem__()
-> def __getitem__(self, item):
(Pdb) n
> /home/rdesmond/.virtualenvs/conan-1.26.0/lib/python3.5/site-packages/conans/model/options.py(216)__getitem__()
-> return self._reqs_options.setdefault(item, PackageOptionValues())
(Pdb) n
--Return--
> /home/rdesmond/.virtualenvs/conan-1.26.0/lib/python3.5/site-packages/conans/model/options.py(216)__getitem__()-><conans.model...x7f5c503ffda0>
-> return self._reqs_options.setdefault(item, PackageOptionValues())
```

Hints from issue discussion:
Thanks for the detailed report! I have provided the necessary ``__contains__`` method so this check is possible: https://github.com/conan-io/conan/pull/7303

In any case, I would also suggest to reconsider the check. It sounds unusual to do the logic based on the existence of an option and not its value. Which option are you checking? Is it ``shared``? Maybe something that could be built-in in the ``shared_library_package_id()``?
Whoa, that was really fast, thank you!  I mirrored it after the check in `shared_library_package_id` itself (why doesn't it's check ` if "shared" not in dep_options` break too?), but with the goal of a base class having similar logic.  Specifically, this was a base class for boost, which has some header-only components and some with a compiled library.  

Boost has no requirements, so this probably shouldn't be there anyhow, but the logic was something along the lines of "if it's header-only the requirements never matter; if it's has a shared option they should follow the `shared_library_package_id`'s logic."

Either way, I was confused when my computer nearly fell over from standard python, so the fix is quite welcome!

```python
class ConanInfo(object):
    ...
    def shared_library_package_id(self):
        if self.full_options.shared:
            for dep_name in self.requires.pkg_names:
                dep_options = self.full_options[dep_name]
                if "shared" not in dep_options or not self.full_options[dep_name].shared:
                    self.requires[dep_name].package_revision_mode()
    ...
```
Good point. This is because internally, it is checking against ``self.info.full_options``, and the attribute check there doesn't raise if the attribute (in this case the ``shared`` option) is not defined. Same happens if you directly test the ``self.info.options.whatever``, it will not fail if not existing. I have added those cases to the tests in the PR too.

Commit message: fix tests

Patch to review:
diff --git a/conans/model/info.py b/conans/model/info.py
index 59bfaf5b186..e1e0b6a2a6a 100644
--- a/conans/model/info.py
+++ b/conans/model/info.py
@@ -617,10 +617,10 @@ def default_std_non_matching(self):
             self.settings.compiler.cppstd = self.full_settings.compiler.cppstd
 
     def shared_library_package_id(self):
-        if self.full_options.shared:
+        if "shared" in self.full_options and self.full_options.shared:
             for dep_name in self.requires.pkg_names:
                 dep_options = self.full_options[dep_name]
-                if "shared" not in dep_options or not self.full_options[dep_name].shared:
+                if "shared" not in dep_options or not dep_options.shared:
                     self.requires[dep_name].package_revision_mode()
 
     def parent_compatible(self, *_, **kwargs):
diff --git a/conans/model/options.py b/conans/model/options.py
index 403e7ecccca..d58702a6736 100644
--- a/conans/model/options.py
+++ b/conans/model/options.py
@@ -22,8 +22,8 @@ def option_not_exist_msg(option_name, existing_options):
     """ Someone is referencing an option that is not available in the current package
     options
     """
-    result = ["'options.%s' doesn't exist" % option_name]
-    result.append("Possible options are %s" % existing_options or "none")
+    result = ["option '%s' doesn't exist" % option_name,
+              "Possible options are %s" % existing_options or "none"]
     return "\n".join(result)
 
 
@@ -59,6 +59,7 @@ class PackageOptionValues(object):
     def __init__(self):
         self._dict = {}  # {option_name: PackageOptionValue}
         self._modified = {}
+        self._freeze = False
 
     def __bool__(self):
         return bool(self._dict)
@@ -71,7 +72,7 @@ def __nonzero__(self):
 
     def __getattr__(self, attr):
         if attr not in self._dict:
-            return None
+            raise ConanException(option_not_exist_msg(attr, list(self._dict.keys())))
         return self._dict[attr]
 
     def __delattr__(self, attr):
@@ -212,6 +213,9 @@ def descope_options(self, name):
     def clear_unscoped_options(self):
         self._package_values.clear()
 
+    def __contains__(self, item):
+        return item in self._package_values
+
     def __getitem__(self, item):
         return self._reqs_options.setdefault(item, PackageOptionValues())
 
@@ -283,16 +287,14 @@ def loads(text):
 
     @property
     def sha(self):
-        result = []
-        result.append(self._package_values.sha)
+        result = [self._package_values.sha]
         for key in sorted(list(self._reqs_options.keys())):
             result.append(self._reqs_options[key].sha)
         return sha1('\n'.join(result).encode())
 
     def serialize(self):
-        ret = {}
-        ret["options"] = self._package_values.serialize()
-        ret["req_options"] = {}
+        ret = {"options": self._package_values.serialize(),
+               "req_options": {}}
         for name, values in self._reqs_options.items():
             ret["req_options"][name] = values.serialize()
         return ret
diff --git a/conans/test/functional/command/info/info_options_test.py b/conans/test/functional/command/info/info_options_test.py
index 4d6c23d7d2f..0d3c49dc303 100644
--- a/conans/test/functional/command/info/info_options_test.py
+++ b/conans/test/functional/command/info/info_options_test.py
@@ -6,8 +6,7 @@
 class InfoOptionsTest(unittest.TestCase):
 
     def info_options_test(self):
-        """ packages with dash
-        """
+        # packages with dash
         client = TestClient()
         client.run('new My-Package/1.3@myuser/testing -t')
         # assert they are correct at least
@@ -23,9 +22,9 @@ def info_options_test(self):
 
         # errors
         client.run("info . -o shared2=True", assert_error=True)
-        self.assertIn("'options.shared2' doesn't exist", client.out)
+        self.assertIn("option 'shared2' doesn't exist", client.out)
         client.run("info . -o My-Package:shared2=True", assert_error=True)
-        self.assertIn("'options.shared2' doesn't exist", client.out)
+        self.assertIn("option 'shared2' doesn't exist", client.out)
 
     def info_wrong_options_test(self):
         # https://github.com/conan-io/conan/issues/2202
diff --git a/conans/test/functional/options/options_test.py b/conans/test/functional/options/options_test.py
index 1716f2878db..4b163262376 100644
--- a/conans/test/functional/options/options_test.py
+++ b/conans/test/functional/options/options_test.py
@@ -15,11 +15,7 @@ class Pkg(ConanFile):
     def configure(self):
         self.output.info("BUILD SHARED: %s" % self.options.shared)
 """
-        test = """from conans import ConanFile
-class Pkg(ConanFile):
-    def test(self):
-        pass
-"""
+        test = GenConanfile().with_test("pass")
         client.save({"conanfile.py": conanfile})
         client.run("create . Pkg/0.1@user/testing -o *:shared=1")
         self.assertIn("Pkg/0.1@user/testing: BUILD SHARED: 1", client.out)
@@ -33,37 +29,29 @@ def test(self):
         client.run("create . Pkg/0.1@user/testing -o Pkg:shared=2")
         self.assertIn("Pkg/0.1@user/testing: BUILD SHARED: 2", client.out)
         client.run("create . Pkg/0.1@user/testing -o shared=1", assert_error=True)
-        self.assertIn("'options.shared' doesn't exist", client.out)
+        self.assertIn("option 'shared' doesn't exist", client.out)
 
-        conanfile = """from conans import ConanFile
-class Pkg(ConanFile):
-    pass
-"""
-        client.save({"conanfile.py": conanfile}, clean_first=True)
+        client.save({"conanfile.py": GenConanfile()}, clean_first=True)
         client.run("create . Pkg/0.1@user/testing -o *:shared=True")
         self.assertIn("Pkg/0.1@user/testing: Calling build()", client.out)
         client.run("create . Pkg/0.1@user/testing -o shared=False", assert_error=True)
-        self.assertIn("'options.shared' doesn't exist", client.out)
+        self.assertIn("option 'shared' doesn't exist", client.out)
         # With test_package
         client.save({"conanfile.py": conanfile,
                      "test_package/conanfile.py": test})
-        client.run("create . Pkg/0.1@user/testing -o *:shared=True")
-        self.assertIn("Pkg/0.1@user/testing: Calling build()", client.out)
-        self.assertIn("Pkg/0.1@user/testing (test package): Calling build()", client.out)
+        client.run("create . Pkg/0.1@user/testing -o *:shared=True", assert_error=True)
+        self.assertIn("ERROR: Pkg/0.1@user/testing: 'True' is not a valid 'options.shared' value",
+                      client.out)
 
     def general_scope_priorities_test(self):
         client = TestClient()
-        conanfile = """from conans import ConanFile
-class Pkg(ConanFile):
-    options = {"shared": ["1", "2", "3"]}
-    def configure(self):
-        self.output.info("BUILD SHARED: %s" % self.options.shared)
-"""
-        test = """from conans import ConanFile
-class Pkg(ConanFile):
-    def test(self):
-        pass
-"""
+        conanfile = textwrap.dedent("""
+            from conans import ConanFile
+            class Pkg(ConanFile):
+                options = {"shared": ["1", "2", "3"]}
+                def configure(self):
+                    self.output.info("BUILD SHARED: %s" % self.options.shared)
+            """)
         client.save({"conanfile.py": conanfile})
         # Consumer has priority
         client.run("create . Pkg/0.1@user/testing -o *:shared=1 -o shared=2")
@@ -73,7 +61,7 @@ def test(self):
         self.assertIn("Pkg/0.1@user/testing: BUILD SHARED: 3", client.out)
         # With test_package
         client.save({"conanfile.py": conanfile,
-                     "test_package/conanfile.py": test})
+                     "test_package/conanfile.py": GenConanfile().with_test("pass")})
         # Sorted (longest, alphabetical) patterns, have priority
         client.run("create . Pkg/0.1@user/testing -o *:shared=1 -o Pkg:shared=2")
         self.assertIn("Pkg/0.1@user/testing: BUILD SHARED: 2", client.out)
@@ -205,32 +193,34 @@ def configure(self):
     def general_scope_options_test(self):
         # https://github.com/conan-io/conan/issues/2538
         client = TestClient()
-        conanfile_libA = """from conans import ConanFile
-class LibA(ConanFile):
-    options = {"shared": [True, False]}
+        conanfile_liba = textwrap.dedent("""
+            from conans import ConanFile
+            class LibA(ConanFile):
+                options = {"shared": [True, False]}
 
-    def configure(self):
-        self.output.info("shared=%s" % self.options.shared)
-    """
-        client.save({"conanfile.py": conanfile_libA})
+                def configure(self):
+                    self.output.info("shared=%s" % self.options.shared)
+                """)
+        client.save({"conanfile.py": conanfile_liba})
         client.run("create . libA/0.1@danimtb/testing -o *:shared=True")
         self.assertIn("libA/0.1@danimtb/testing: shared=True", client.out)
 
-        conanfile_libB = """from conans import ConanFile
-class LibB(ConanFile):
-    options = {"shared": [True, False]}
-    requires = "libA/0.1@danimtb/testing"
+        conanfile_libb = textwrap.dedent("""
+            from conans import ConanFile
+            class LibB(ConanFile):
+                options = {"shared": [True, False]}
+                requires = "libA/0.1@danimtb/testing"
 
-    def configure(self):
-        self.options["*"].shared = self.options.shared
-        self.output.info("shared=%s" % self.options.shared)
-    """
+                def configure(self):
+                    self.options["*"].shared = self.options.shared
+                    self.output.info("shared=%s" % self.options.shared)
+                """)
 
         for without_configure_line in [True, False]:
             if without_configure_line:
-                conanfile = conanfile_libB.replace(
-                    "        self.options[\"*\"].shared = self.options.shared", "")
-
+                conanfile = conanfile_libb.replace("self.options[", "#")
+            else:
+                conanfile = conanfile_libb
             client.save({"conanfile.py": conanfile})
 
             # Test info
@@ -336,3 +326,16 @@ def package_id(self):
             client.run("create . pkg/0.1@user/testing %s" % options)
             self.assertIn("liba/0.1@user/testing:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 - Cache",
                           client.out)
+
+    def missing_shared_option_package_id_test(self):
+        client = TestClient()
+
+        consumer = textwrap.dedent("""
+            from conans import ConanFile
+            class Pkg(ConanFile):
+                def package_id(self):
+                    self.info.shared_library_package_id()
+            """)
+        client.save({"conanfile.py": consumer})
+        client.run("create . pkg/0.1@user/testing")
+        self.assertIn("pkg/0.1@user/testing: Created package ", client.out)
diff --git a/conans/test/functional/package_id/package_id_test.py b/conans/test/functional/package_id/package_id_test.py
index 94580f7c188..b3f9d162837 100644
--- a/conans/test/functional/package_id/package_id_test.py
+++ b/conans/test/functional/package_id/package_id_test.py
@@ -1,3 +1,4 @@
+import textwrap
 import unittest
 
 from conans.test.utils.tools import NO_SETTINGS_PACKAGE_ID, TestClient, TestServer
@@ -78,3 +79,40 @@ def package(self):
         client.run("install test/0.1@danimtb/testing")
         client.run("search test/0.1@danimtb/testing")
         self.assertIn("compiler.version: kk=kk", client.out)
+
+    def option_in_test(self):
+        # https://github.com/conan-io/conan/issues/7299
+        conanfile = textwrap.dedent("""
+            from conans import ConanFile
+
+            class TestConan(ConanFile):
+                options = {"fpic": [True, False]}
+                default_options = {"fpic": True}
+                def package_id(self):
+                    if "fpic" in self.options:
+                        self.output.info("fpic is an option!!!")
+                    if "fpic" in self.info.options:
+                        self.output.info("fpic is an info.option!!!")
+                    if "other" not in self.options:
+                        self.output.info("other is not an option!!!")
+                    if "other" not in self.info.options:
+                        self.output.info("other is not an info.option!!!")
+                    try:
+                        self.options.whatever
+                    except Exception as e:
+                        self.output.error("OPTIONS: %s" % e)
+                    try:
+                        self.info.options.whatever
+                    except Exception as e:
+                        self.output.error("INFO: %s" % e)
+
+            """)
+        client = TestClient()
+        client.save({"conanfile.py": conanfile})
+        client.run("create . Pkg/0.1@user/testing")
+        self.assertIn("fpic is an option!!!", client.out)
+        self.assertIn("fpic is an info.option!!!", client.out)
+        self.assertIn("other is not an option!!!", client.out)
+        self.assertIn("other is not an info.option!!!", client.out)
+        self.assertIn("ERROR: OPTIONS: option 'whatever' doesn't exist", client.out)
+        self.assertIn("ERROR: INFO: option 'whatever' doesn't exist", client.out)
diff --git a/conans/test/functional/settings/remove_subsetting_test.py b/conans/test/functional/settings/remove_subsetting_test.py
index d89f08d0cec..40bbe38c142 100644
--- a/conans/test/functional/settings/remove_subsetting_test.py
+++ b/conans/test/functional/settings/remove_subsetting_test.py
@@ -27,7 +27,7 @@ def build(self):
         client.current_folder = build_folder
         client.run("install ..")
         client.run("build ..", assert_error=True)
-        self.assertIn("ConanException: 'options.opt2' doesn't exist", client.out)
+        self.assertIn("ConanException: option 'opt2' doesn't exist", client.out)
         self.assertIn("Possible options are ['opt1']", client.out)
 
     def remove_setting_test(self):


Code review comments:
diff_hunk: @@ -33,37 +29,29 @@ def test(self):
         client.run("create . Pkg/0.1@user/testing -o Pkg:shared=2")
         self.assertIn("Pkg/0.1@user/testing: BUILD SHARED: 2", client.out)
         client.run("create . Pkg/0.1@user/testing -o shared=1", assert_error=True)
-        self.assertIn("'options.shared' doesn't exist", client.out)
+        self.assertIn("option 'shared' doesn't exist", client.out)
 
-        conanfile = """from conans import ConanFile
-class Pkg(ConanFile):
-    pass
-"""
-        client.save({"conanfile.py": conanfile}, clean_first=True)
+        client.save({"conanfile.py": GenConanfile()}, clean_first=True)
         client.run("create . Pkg/0.1@user/testing -o *:shared=True")
         self.assertIn("Pkg/0.1@user/testing: Calling build()", client.out)
         client.run("create . Pkg/0.1@user/testing -o shared=False", assert_error=True)
-        self.assertIn("'options.shared' doesn't exist", client.out)
+        self.assertIn("option 'shared' doesn't exist", client.out)
         # With test_package
         client.save({"conanfile.py": conanfile,
                      "test_package/conanfile.py": test})
-        client.run("create . Pkg/0.1@user/testing -o *:shared=True")
-        self.assertIn("Pkg/0.1@user/testing: Calling build()", client.out)
-        self.assertIn("Pkg/0.1@user/testing (test package): Calling build()", client.out)
+        client.run("create . Pkg/0.1@user/testing -o *:shared=True", assert_error=True)
+        self.assertIn("ERROR: Pkg/0.1@user/testing: 'True' is not a valid 'options.shared' value",
+                      client.out)
path: conans/test/functional/options/options_test.py
text: @user2:
This test is checking that `shared` in `conan create . Pkg/0.1@user1/testing -o shared=False` refers to a specific package and raises because the option doesn't exist; but with `conan create . Pkg/0.1@user1/testing -o *:shared=True` it doesn't raise because it is not specific to any package.



@author:
It tries to assign a ``True`` value to an option that only has ``[1, 2]`` as possible values. I find it reasonable to fail, isn't it?

@user2:
oh! I see, you have changed the `conanfile` itself. Before it was:

```
conanfile = """from conans import ConanFile
class Pkg(ConanFile):
    pass
"""
```

now it is a different conanfile. I would keep the same behavior in this test, it is just about writing a `GenConanfile()` 

@author:
Nevermind, I think there is an error, let me check it.

@author:
Yeah, an annoying bug because of using the same test() to test 2 different things. I have splitted the test in 2.