Repository: conan-io/conan

Issue:
[bug] hasattr does not work with cpp_info attributes
### Describe the bug

I am iterating through my dependencies in a custom deployer.
Some packages have empty libdirs in CppInfo (e.g. [xorg-macros](https://github.com/conan-io/conan-center-index/blob/master/recipes/xorg-macros/all/conanfile.py#L77))
Trying to access those (if only to check if they are empty) does not work as there is an assert in the property function.

See [build_info.py](https://github.com/conan-io/conan/blob/develop2/conans/model/build_info.py#L202):
```  
@property
def libdir(self):
    libdirs = self.libdirs
    assert libdirs
    assert len(libdirs) == 1
    return libdirs[0]
```

Unfortunately I can't even check for the existence of the attribute using hasattr as hasattr calls getattr, which calls the property and triggers the same assert.

According to the  [documentation](https://docs.python.org/3/library/functions.html#getattr) getattr should raise [AttributeError](https://docs.python.org/3/library/exceptions.html#AttributeError) is the attribute does not exists.

I understand that the attribute technically *does* exist . But for the user of the api it is as if it doesn't.

Currently I have to use this workaround to test if my dependency has libdirs:
```
def hasattr_cpp_info(dep, attr):
    try:
        return hasattr(dep.cpp_info, attr)
    except AssertionError:
        return False
```

<details><summary>Full example</summary>
<pre>
~/conanhasattr   
❯ cat conanfile.txt              
[requires]
xorg-macros/1.20.0
                                                                                                                    
~/conanhasattr   
❯ cat deployer.py  
def deploy(graph, output_folder: str, **kwargs):
    conanfile = graph.root.conanfile
    for _, dep in conanfile.dependencies.items():
        print(f"Libdirs: {dep.cpp_info.libdir}")
                                                                                                                    
~/conanhasattr   
❯ conan install . --deployer deployer

======== Input profiles ========
Profile host:
[settings]
arch=x86_64
build_type=Release
compiler=gcc
compiler.cppstd=gnu17
compiler.libcxx=libstdc++11
compiler.version=12
os=Linux
[conf]


Profile build:
[settings]
arch=x86_64
build_type=Release
compiler=gcc
compiler.cppstd=gnu17
compiler.libcxx=libstdc++11
compiler.version=12
os=Linux
[conf]



======== Computing dependency graph ========
Graph root
    conanfile.txt: /home/oweissbarth/conanhasattr/conanfile.txt
Requirements
    xorg-macros/1.20.0#b721d2f8ca6804d072820cb551d8c4fa - Cache
Build requirements
    autoconf/2.71#f9307992909d7fb3df459340f1932809 - Cache
    automake/1.16.5#058bda3e21c36c9aa8425daf3c1faf50 - Cache
    m4/1.4.19#b38ced39a01e31fef5435bc634461fd2 - Cache

======== Computing necessary packages ========
Requirements
    xorg-macros/1.20.0#b721d2f8ca6804d072820cb551d8c4fa:da39a3ee5e6b4b0d3255bfef95601890afd80709#ebfa68a737e1864a62efb6f30910c8a6 - Cache
Build requirements
Skipped binaries
    autoconf/2.71, automake/1.16.5, m4/1.4.19

======== Installing packages ========
xorg-macros/1.20.0: Already installed! (1 of 1)
xorg-macros/1.20.0: Appending AUTOMAKE_CONAN_INCLUDES environment variable: /home/oweissbarth/.conan2/p/xorg-f9610002da016/p/bin/share/aclocal
xorg-macros/1.20.0: WARN: The use of 'unix_path_legacy_compat' is deprecated in Conan 2.0 and does not perform path conversions. This is retained for compatibility with Conan 1.x and will be removed in a future version.
WARN: deprecated: Usage of deprecated Conan 1.X features that will be removed in Conan 2.X:
WARN: deprecated:     'cpp_info.names' used in: xorg-macros/1.20.0
WARN: deprecated:     'env_info' used in: xorg-macros/1.20.0

======== Finalizing install (deploy, generators) ========
ERROR: Traceback (most recent call last):
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/cli.py", line 283, in main
    cli.run(args)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/cli.py", line 193, in run
    command.run(self._conan_api, args[0][1:])
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/command.py", line 164, in run
    info = self._method(conan_api, parser, *args)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/commands/install.py", line 74, in install
    conan_api.install.install_consumer(deps_graph, args.generator, source_folder, output_folder,
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/api/subapi/install.py", line 73, in install_consumer
    do_deploys(self.conan_api, deps_graph, deploy, deploy_package, base_folder)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/internal/deploy.py", line 66, in do_deploys
    deployer(graph=graph, output_folder=deploy_folder)
  File "/home/oweissbarth/conanhasattr/deployer.py", line 4, in deploy
    print(f"Libdirs: {dep.cpp_info.libdir}")
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conans/model/build_info.py", line 421, in __getattr__
    return getattr(self._package, attr)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conans/model/build_info.py", line 204, in libdir
    assert libdirs
AssertionError

ERROR: 
                                                                                                                    
~/conanhasattr   
❯ cat deployer2.py                   
def deploy(graph, output_folder: str, **kwargs):
    conanfile = graph.root.conanfile
    for _, dep in conanfile.dependencies.items():
        if not hasattr(dep.cpp_info, "libdir"):
            continue
        print(f"Libdirs: {dep.cpp_info.libdir}")
                                                                                                                    
~/conanhasattr   
❯ conan install . --deployer deployer2

======== Input profiles ========
Profile host:
[settings]
arch=x86_64
build_type=Release
compiler=gcc
compiler.cppstd=gnu17
compiler.libcxx=libstdc++11
compiler.version=12
os=Linux
[conf]


Profile build:
[settings]
arch=x86_64
build_type=Release
compiler=gcc
compiler.cppstd=gnu17
compiler.libcxx=libstdc++11
compiler.version=12
os=Linux
[conf]



======== Computing dependency graph ========
Graph root
    conanfile.txt: /home/oweissbarth/conanhasattr/conanfile.txt
Requirements
    xorg-macros/1.20.0#b721d2f8ca6804d072820cb551d8c4fa - Cache
Build requirements
    autoconf/2.71#f9307992909d7fb3df459340f1932809 - Cache
    automake/1.16.5#058bda3e21c36c9aa8425daf3c1faf50 - Cache
    m4/1.4.19#b38ced39a01e31fef5435bc634461fd2 - Cache

======== Computing necessary packages ========
Requirements
    xorg-macros/1.20.0#b721d2f8ca6804d072820cb551d8c4fa:da39a3ee5e6b4b0d3255bfef95601890afd80709#ebfa68a737e1864a62efb6f30910c8a6 - Cache
Build requirements
Skipped binaries
    autoconf/2.71, automake/1.16.5, m4/1.4.19

======== Installing packages ========
xorg-macros/1.20.0: Already installed! (1 of 1)
xorg-macros/1.20.0: Appending AUTOMAKE_CONAN_INCLUDES environment variable: /home/oweissbarth/.conan2/p/xorg-f9610002da016/p/bin/share/aclocal
xorg-macros/1.20.0: WARN: The use of 'unix_path_legacy_compat' is deprecated in Conan 2.0 and does not perform path conversions. This is retained for compatibility with Conan 1.x and will be removed in a future version.
WARN: deprecated: Usage of deprecated Conan 1.X features that will be removed in Conan 2.X:
WARN: deprecated:     'cpp_info.names' used in: xorg-macros/1.20.0
WARN: deprecated:     'env_info' used in: xorg-macros/1.20.0

======== Finalizing install (deploy, generators) ========
ERROR: Traceback (most recent call last):
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/cli.py", line 283, in main
    cli.run(args)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/cli.py", line 193, in run
    command.run(self._conan_api, args[0][1:])
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/command.py", line 164, in run
    info = self._method(conan_api, parser, *args)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/cli/commands/install.py", line 74, in install
    conan_api.install.install_consumer(deps_graph, args.generator, source_folder, output_folder,
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/api/subapi/install.py", line 73, in install_consumer
    do_deploys(self.conan_api, deps_graph, deploy, deploy_package, base_folder)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conan/internal/deploy.py", line 66, in do_deploys
    deployer(graph=graph, output_folder=deploy_folder)
  File "/home/oweissbarth/conanhasattr/deployer2.py", line 4, in deploy
    if not hasattr(dep.cpp_info, "libdir"):
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conans/model/build_info.py", line 421, in __getattr__
    return getattr(self._package, attr)
  File "/home/oweissbarth/.local/lib/python3.10/site-packages/conans/model/build_info.py", line 204, in libdir
    assert libdirs
AssertionError

ERROR: 
</pre>
</details> 

Thanks in advance!

### How to reproduce it

_No response_


Discussion:
Hi @oweissbarth thanks for your report. As you've seen in the code, the singular accessors are a shorthand for when the different dir variables only have 1 element. If you need to be generic about those, you'll actually want to check for the length of the plural forms, which always exist, so use `self.libdirs` instead of `self.libdir` etc. 

Hope this helps, else happy to help where necessary :)
@AbrilRBS Now i feel stupid. :)  Thanks for the clarification.
@AbrilRBS That is a good workaround. But still, `getattr(dep.cpp_info, "whatever")` should raise an `AttributeError` when the attribute doesn't exist. You're implementing `__get_attr__()`, and in my opinion it doesn't behave according to the Python object model.
https://docs.python.org/3/reference/datamodel.html#object.__getattr__
> This method should either return the (computed) attribute value or raise an [AttributeError](https://docs.python.org/3/library/exceptions.html#AttributeError) exception.
@haferburg Conan ``conanfile.py`` is not pure Python. It is a custom DSL on top of Python. While we generally try to be as pythonic as possible and follow Python model, in some scenarios we might deviate from it. 
In the case of ``cpp_info`` I think there are some historical reasons, when users were dynamically adding fields, and it was expected to be able to read them with ``self.cpp_info.myfield`` and return None without exceptions, as this results in simpler recipes for people that are not that used to Python code.
@memsharded I see. Thank you for the explanation, James.

Would you consider adding an explanation to the assert? I think this would have helped us realizing the problem.

    assert libdirs, "The libdir property is undefined because libdirs is empty. Consider using the libdirs property."

Thank you.

I asked Oliver to reopen the ticket. Feel free to close, I'm sure he won't mind.
Yes, I agree that a better message in the assertion could help. I'll leave this open to add it, thanks for the feedback!

Actually, we might actually change it for a ``ConanException``, to print a better error message with more scope, and avoiding stack traces.
We have discussed it, we will improve the UX by launching a proper ``ConanException`` with a better error message explaining the situation.



Patch:
diff --git a/conans/model/build_info.py b/conans/model/build_info.py
index fa50ed17056..34a9d5c0542 100644
--- a/conans/model/build_info.py
+++ b/conans/model/build_info.py
@@ -194,22 +194,28 @@ def frameworkdirs(self, value):
     @property
     def bindir(self):
         bindirs = self.bindirs
-        assert bindirs
-        assert len(bindirs) == 1
+        if not bindirs or len(bindirs) != 1:
+            raise ConanException(f"The bindir property is undefined because bindirs "
+                                 f"{'is empty' if not bindirs else 'has more than one element'}."
+                                 f" Consider using the bindirs property.")
         return bindirs[0]
 
     @property
     def libdir(self):
         libdirs = self.libdirs
-        assert libdirs
-        assert len(libdirs) == 1
+        if not libdirs or len(libdirs) != 1:
+            raise ConanException(f"The libdir property is undefined because libdirs "
+                                 f"{'is empty' if not libdirs else 'has more than one element'}."
+                                 f" Consider using the libdirs property.")
         return libdirs[0]
 
     @property
     def includedir(self):
         includedirs = self.includedirs
-        assert includedirs
-        assert len(includedirs) == 1
+        if not includedirs or len(includedirs) != 1:
+            raise ConanException(f"The includedir property is undefined because includedirs "
+                                 f"{'is empty' if not includedirs else 'has more than one element'}."
+                                 f" Consider using the includedirs property.")
         return includedirs[0]
 
     @property


Test patch:
diff --git a/test/integration/conanfile/conanfile_errors_test.py b/test/integration/conanfile/conanfile_errors_test.py
index 028c974ba91..c10dc1c0bf0 100644
--- a/test/integration/conanfile/conanfile_errors_test.py
+++ b/test/integration/conanfile/conanfile_errors_test.py
@@ -192,3 +192,27 @@ def requirements(self):
     c.save({"conanfile.py": conanfile})
     c.run("create .", assert_error=True)
     assert "ERROR: hello/0.1: Dependencies options were defined incorrectly." in c.out
+
+
+@pytest.mark.parametrize("property_name", ["libdir", "bindir", "includedir"])
+@pytest.mark.parametrize("property_content", [[], ["mydir1", "mydir2"]])
+def test_shorthand_bad_interface(property_name, property_content):
+    c = TestClient(light=True)
+    conanfile = textwrap.dedent(f"""
+        from conan import ConanFile
+
+        class HelloConan(ConanFile):
+            name = "hello"
+            version = "0.1"
+
+            def package_info(self):
+                self.cpp_info.{property_name}s = {property_content}
+                self.output.info(self.cpp_info.{property_name})
+        """)
+    c.save({"conanfile.py": conanfile})
+    c.run("create .", assert_error=True)
+    if property_content:
+        assert f"The {property_name} property is undefined because {property_name}s has more than one element." in c.out
+    else:
+        assert f"The {property_name} property is undefined because {property_name}s is empty." in c.out
+