Skip to content

Commit 387e59f

Browse files
authored
Merge pull request #1702 from python-gitlab/jlvillal/attribute_help
chore: attempt to be more informative for missing attributes
2 parents e6582a3 + 1839c9e commit 387e59f

File tree

4 files changed

+71
-5
lines changed

4 files changed

+71
-5
lines changed

docs/faq.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ I cannot edit the merge request / issue I've just retrieved
1616
See the :ref:`merge requests example <merge_requests_examples>` and the
1717
:ref:`issues examples <issues_examples>`.
1818

19+
.. _attribute_error_list:
20+
21+
I get an ``AttributeError`` when accessing attributes of an object retrieved via a ``list()`` call.
22+
Fetching a list of objects, doesn’t always include all attributes in the
23+
objects. To retrieve an object with all attributes use a ``get()`` call.
24+
25+
Example with projects::
26+
27+
for projects in gl.projects.list():
28+
# Retrieve project object with all attributes
29+
project = gl.projects.get(project.id)
30+
1931
How can I clone the repository of a project?
2032
python-gitlab doesn't provide an API to clone a project. You have to use a
2133
git library or call the ``git`` command.

gitlab/base.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1717

1818
import importlib
19+
import textwrap
1920
from types import ModuleType
2021
from typing import Any, Dict, Iterable, NamedTuple, Optional, Tuple, Type
2122

23+
import gitlab
2224
from gitlab import types as g_types
2325
from gitlab.exceptions import GitlabParsingError
2426

@@ -32,6 +34,12 @@
3234
]
3335

3436

37+
_URL_ATTRIBUTE_ERROR = (
38+
f"https://python-gitlab.readthedocs.io/en/{gitlab.__version__}/"
39+
f"faq.html#attribute-error-list"
40+
)
41+
42+
3543
class RESTObject(object):
3644
"""Represents an object built from server data.
3745
@@ -45,13 +53,20 @@ class RESTObject(object):
4553

4654
_id_attr: Optional[str] = "id"
4755
_attrs: Dict[str, Any]
56+
_created_from_list: bool # Indicates if object was created from a list() action
4857
_module: ModuleType
4958
_parent_attrs: Dict[str, Any]
5059
_short_print_attr: Optional[str] = None
5160
_updated_attrs: Dict[str, Any]
5261
manager: "RESTManager"
5362

54-
def __init__(self, manager: "RESTManager", attrs: Dict[str, Any]) -> None:
63+
def __init__(
64+
self,
65+
manager: "RESTManager",
66+
attrs: Dict[str, Any],
67+
*,
68+
created_from_list: bool = False,
69+
) -> None:
5570
if not isinstance(attrs, dict):
5671
raise GitlabParsingError(
5772
"Attempted to initialize RESTObject with a non-dictionary value: "
@@ -64,6 +79,7 @@ def __init__(self, manager: "RESTManager", attrs: Dict[str, Any]) -> None:
6479
"_attrs": attrs,
6580
"_updated_attrs": {},
6681
"_module": importlib.import_module(self.__module__),
82+
"_created_from_list": created_from_list,
6783
}
6884
)
6985
self.__dict__["_parent_attrs"] = self.manager.parent_attrs
@@ -106,8 +122,22 @@ def __getattr__(self, name: str) -> Any:
106122
except KeyError:
107123
try:
108124
return self.__dict__["_parent_attrs"][name]
109-
except KeyError:
110-
raise AttributeError(name)
125+
except KeyError as exc:
126+
message = (
127+
f"{type(self).__name__!r} object has no attribute {name!r}"
128+
)
129+
if self._created_from_list:
130+
message = (
131+
f"{message}\n\n"
132+
+ textwrap.fill(
133+
f"{self.__class__!r} was created via a list() call and "
134+
f"only a subset of the data may be present. To ensure "
135+
f"all data is present get the object using a "
136+
f"get(object.id) call. For more details, see:"
137+
)
138+
+ f"\n\n{_URL_ATTRIBUTE_ERROR}"
139+
)
140+
raise AttributeError(message) from exc
111141

112142
def __setattr__(self, name: str, value: Any) -> None:
113143
self.__dict__["_updated_attrs"][name] = value
@@ -229,7 +259,7 @@ def __next__(self) -> RESTObject:
229259

230260
def next(self) -> RESTObject:
231261
data = self._list.next()
232-
return self._obj_cls(self.manager, data)
262+
return self._obj_cls(self.manager, data, created_from_list=True)
233263

234264
@property
235265
def current_page(self) -> int:

gitlab/mixins.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def list(self, **kwargs: Any) -> Union[base.RESTObjectList, List[base.RESTObject
240240
assert self._obj_cls is not None
241241
obj = self.gitlab.http_list(path, **data)
242242
if isinstance(obj, list):
243-
return [self._obj_cls(self, item) for item in obj]
243+
return [self._obj_cls(self, item, created_from_list=True) for item in obj]
244244
else:
245245
return base.RESTObjectList(self, self._obj_cls, obj)
246246

tests/unit/test_base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,30 @@ def test_instantiate_non_dict(self, fake_gitlab, fake_manager):
9090
with pytest.raises(gitlab.exceptions.GitlabParsingError):
9191
FakeObject(fake_manager, ["a", "list", "fails"])
9292

93+
def test_missing_attribute_does_not_raise_custom(self, fake_gitlab, fake_manager):
94+
"""Ensure a missing attribute does not raise our custom error message
95+
if the RESTObject was not created from a list"""
96+
obj = FakeObject(manager=fake_manager, attrs={"foo": "bar"})
97+
with pytest.raises(AttributeError) as excinfo:
98+
obj.missing_attribute
99+
exc_str = str(excinfo.value)
100+
assert "missing_attribute" in exc_str
101+
assert "was created via a list()" not in exc_str
102+
assert base._URL_ATTRIBUTE_ERROR not in exc_str
103+
104+
def test_missing_attribute_from_list_raises_custom(self, fake_gitlab, fake_manager):
105+
"""Ensure a missing attribute raises our custom error message if the
106+
RESTObject was created from a list"""
107+
obj = FakeObject(
108+
manager=fake_manager, attrs={"foo": "bar"}, created_from_list=True
109+
)
110+
with pytest.raises(AttributeError) as excinfo:
111+
obj.missing_attribute
112+
exc_str = str(excinfo.value)
113+
assert "missing_attribute" in exc_str
114+
assert "was created via a list()" in exc_str
115+
assert base._URL_ATTRIBUTE_ERROR in exc_str
116+
93117
def test_picklability(self, fake_manager):
94118
obj = FakeObject(fake_manager, {"foo": "bar"})
95119
original_obj_module = obj._module

0 commit comments

Comments
 (0)