Repository: python-gitlab/python-gitlab

Issue:
`ProjectPipeline` from `.list(as_list=False)` has fewer information than the one from `.get(id)`
## Description of the problem, including code/CLI snippet
```
project = gl.projects.get('...')
all_pipelines = project.pipelines.list(as_list=False)
pipelines = itertools.islice(all_pipelines, search_limit)
print(pipelines[0].duration)
```

## Expected Behavior
Duration of the pipeline is returned.

## Actual Behavior
`AttributeError` is raised. Same happens with many other attributes. Only when using `refresh()` on a pipeline, the object has all of the expected information.

Objects fetched via `project.pipelines.get(id)` do not suffer from this problem.

```
KeyError                                  Traceback (most recent call last)
~/.virtualenvs/taf/lib/python3.7/site-packages/gitlab/base.py in __getattr__(self, name)
     55         try:
---> 56             return self.__dict__['_updated_attrs'][name]
     57         except KeyError:

KeyError: 'duration'

During handling of the above exception, another exception occurred:

KeyError                                  Traceback (most recent call last)
~/.virtualenvs/taf/lib/python3.7/site-packages/gitlab/base.py in __getattr__(self, name)
     58             try:
---> 59                 value = self.__dict__['_attrs'][name]
     60

KeyError: 'duration'

During handling of the above exception, another exception occurred:

KeyError                                  Traceback (most recent call last)
~/.virtualenvs/taf/lib/python3.7/site-packages/gitlab/base.py in __getattr__(self, name)
     78                 try:
---> 79                     return self.__dict__['_parent_attrs'][name]
     80                 except KeyError:

KeyError: 'duration'

During handling of the above exception, another exception occurred:

AttributeError                            Traceback (most recent call last)
<ipython-input-25-919ee3634190> in <module>
----> 1 gather_data(proj, 200, branch_name='master')

~/taf/taf.tools.pipeline_duration/app.py in gather_data(project, search_limit, branch_name, passed_only)
     40     return durations

~/.virtualenvs/taf/lib/python3.7/site-packages/gitlab/base.py in __getattr__(self, name)
     79                     return self.__dict__['_parent_attrs'][name]
     80                 except KeyError:
---> 81                     raise AttributeError(name)
     82
     83     def __setattr__(self, name, value):

AttributeError: duration
```

## Specifications

  - python-gitlab version: 1.8.0
  - API version you are using (v3/v4): v4
  - Gitlab server version (or gitlab.com): 12.7.6




Discussion:
It's just how the Gitlab's APIs work ([[1]], [[2]]). This behavior is not limited to just pipelines. For instance, you can also observe it for Merge Requests. If `python-gitlab` wanted to always have those it would either cost a lot of extra API calls or a rework of the library to have the additional fields fetched lazily.

I was about to suggest using `simple` views for listing if you're always going to fetch the details anyway but it seems those are not available for pipelines endpoints. 

[1]: https://docs.gitlab.com/ee/api/pipelines.html#list-project-pipelines
[2]: https://docs.gitlab.com/ee/api/pipelines.html#get-a-single-pipeline
I'm satisfied with that explanation, but since mapping from Gitlab-API to python-gitlab objects/attributes isn't really straightforward, it would be nice to have it documented. Current behavior is misleading.

Resolving patch:
diff --git a/gitlab/base.py b/gitlab/base.py
--- a/gitlab/base.py
+++ b/gitlab/base.py
@@ -16,9 +16,11 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import importlib
+import textwrap
 from types import ModuleType
 from typing import Any, Dict, Iterable, NamedTuple, Optional, Tuple, Type
 
+import gitlab
 from gitlab import types as g_types
 from gitlab.exceptions import GitlabParsingError
 
@@ -32,6 +34,12 @@
 ]
 
 
+_URL_ATTRIBUTE_ERROR = (
+    f"https://python-gitlab.readthedocs.io/en/{gitlab.__version__}/"
+    f"faq.html#attribute-error-list"
+)
+
+
 class RESTObject(object):
     """Represents an object built from server data.
 
@@ -45,13 +53,20 @@ class RESTObject(object):
 
     _id_attr: Optional[str] = "id"
     _attrs: Dict[str, Any]
+    _created_from_list: bool  # Indicates if object was created from a list() action
     _module: ModuleType
     _parent_attrs: Dict[str, Any]
     _short_print_attr: Optional[str] = None
     _updated_attrs: Dict[str, Any]
     manager: "RESTManager"
 
-    def __init__(self, manager: "RESTManager", attrs: Dict[str, Any]) -> None:
+    def __init__(
+        self,
+        manager: "RESTManager",
+        attrs: Dict[str, Any],
+        *,
+        created_from_list: bool = False,
+    ) -> None:
         if not isinstance(attrs, dict):
             raise GitlabParsingError(
                 "Attempted to initialize RESTObject with a non-dictionary value: "
@@ -64,6 +79,7 @@ def __init__(self, manager: "RESTManager", attrs: Dict[str, Any]) -> None:
                 "_attrs": attrs,
                 "_updated_attrs": {},
                 "_module": importlib.import_module(self.__module__),
+                "_created_from_list": created_from_list,
             }
         )
         self.__dict__["_parent_attrs"] = self.manager.parent_attrs
@@ -106,8 +122,22 @@ def __getattr__(self, name: str) -> Any:
             except KeyError:
                 try:
                     return self.__dict__["_parent_attrs"][name]
-                except KeyError:
-                    raise AttributeError(name)
+                except KeyError as exc:
+                    message = (
+                        f"{type(self).__name__!r} object has no attribute {name!r}"
+                    )
+                    if self._created_from_list:
+                        message = (
+                            f"{message}\n\n"
+                            + textwrap.fill(
+                                f"{self.__class__!r} was created via a list() call and "
+                                f"only a subset of the data may be present. To ensure "
+                                f"all data is present get the object using a "
+                                f"get(object.id) call. For more details, see:"
+                            )
+                            + f"\n\n{_URL_ATTRIBUTE_ERROR}"
+                        )
+                    raise AttributeError(message) from exc
 
     def __setattr__(self, name: str, value: Any) -> None:
         self.__dict__["_updated_attrs"][name] = value
@@ -229,7 +259,7 @@ def __next__(self) -> RESTObject:
 
     def next(self) -> RESTObject:
         data = self._list.next()
-        return self._obj_cls(self.manager, data)
+        return self._obj_cls(self.manager, data, created_from_list=True)
 
     @property
     def current_page(self) -> int:
diff --git a/gitlab/mixins.py b/gitlab/mixins.py
--- a/gitlab/mixins.py
+++ b/gitlab/mixins.py
@@ -240,7 +240,7 @@ def list(self, **kwargs: Any) -> Union[base.RESTObjectList, List[base.RESTObject
             assert self._obj_cls is not None
         obj = self.gitlab.http_list(path, **data)
         if isinstance(obj, list):
-            return [self._obj_cls(self, item) for item in obj]
+            return [self._obj_cls(self, item, created_from_list=True) for item in obj]
         else:
             return base.RESTObjectList(self, self._obj_cls, obj)
 


Test patch:
diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py
--- a/tests/unit/test_base.py
+++ b/tests/unit/test_base.py
@@ -90,6 +90,30 @@ def test_instantiate_non_dict(self, fake_gitlab, fake_manager):
         with pytest.raises(gitlab.exceptions.GitlabParsingError):
             FakeObject(fake_manager, ["a", "list", "fails"])
 
+    def test_missing_attribute_does_not_raise_custom(self, fake_gitlab, fake_manager):
+        """Ensure a missing attribute does not raise our custom error message
+        if the RESTObject was not created from a list"""
+        obj = FakeObject(manager=fake_manager, attrs={"foo": "bar"})
+        with pytest.raises(AttributeError) as excinfo:
+            obj.missing_attribute
+        exc_str = str(excinfo.value)
+        assert "missing_attribute" in exc_str
+        assert "was created via a list()" not in exc_str
+        assert base._URL_ATTRIBUTE_ERROR not in exc_str
+
+    def test_missing_attribute_from_list_raises_custom(self, fake_gitlab, fake_manager):
+        """Ensure a missing attribute raises our custom error message if the
+        RESTObject was created from a list"""
+        obj = FakeObject(
+            manager=fake_manager, attrs={"foo": "bar"}, created_from_list=True
+        )
+        with pytest.raises(AttributeError) as excinfo:
+            obj.missing_attribute
+        exc_str = str(excinfo.value)
+        assert "missing_attribute" in exc_str
+        assert "was created via a list()" in exc_str
+        assert base._URL_ATTRIBUTE_ERROR in exc_str
+
     def test_picklability(self, fake_manager):
         obj = FakeObject(fake_manager, {"foo": "bar"})
         original_obj_module = obj._module