Skip to content

Commit 29e0bae

Browse files
author
Gauvain Pocentek
committed
Rework the manager and object classes
Add new RESTObject and RESTManager base class, linked to a bunch of Mixin class to implement the actual CRUD methods. Object are generated by the managers, and special cases are handled in the derivated classes. Both ways (old and new) can be used together, migrate only a few v4 objects to the new method as a POC. TODO: handle managers on generated objects (have to deal with attributes in the URLs).
1 parent b7298de commit 29e0bae

File tree

3 files changed

+399
-106
lines changed

3 files changed

+399
-106
lines changed

gitlab/__init__.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -644,9 +644,12 @@ def http_request(self, verb, path, query_data={}, post_data={},
644644
opts = self._get_session_opts(content_type='application/json')
645645
result = self.session.request(verb, url, json=post_data,
646646
params=params, stream=streamed, **opts)
647-
if not (200 <= result.status_code < 300):
648-
raise GitlabHttpError(response_code=result.status_code)
649-
return result
647+
if 200 <= result.status_code < 300:
648+
return result
649+
650+
651+
raise GitlabHttpError(response_code=result.status_code,
652+
error_message=result.content)
650653

651654
def http_get(self, path, query_data={}, streamed=False, **kwargs):
652655
"""Make a GET request to the Gitlab server.
@@ -748,7 +751,7 @@ def http_put(self, path, query_data={}, post_data={}, **kwargs):
748751
GitlabHttpError: When the return code is not 2xx
749752
GitlabParsingError: IF the json data could not be parsed
750753
"""
751-
result = self.hhtp_request('put', path, query_data=query_data,
754+
result = self.http_request('put', path, query_data=query_data,
752755
post_data=post_data, **kwargs)
753756
try:
754757
return result.json()
@@ -808,6 +811,9 @@ def _query(self, url, query_data={}, **kwargs):
808811
def __iter__(self):
809812
return self
810813

814+
def __len__(self):
815+
return self._total_pages
816+
811817
def __next__(self):
812818
return self.next()
813819

@@ -819,6 +825,6 @@ def next(self):
819825
except IndexError:
820826
if self._next_url:
821827
self._query(self._next_url)
822-
return self._data[self._current]
828+
return self.next()
823829

824830
raise StopIteration

gitlab/base.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,3 +531,317 @@ def __eq__(self, other):
531531

532532
def __ne__(self, other):
533533
return not self.__eq__(other)
534+
535+
536+
class SaveMixin(object):
537+
"""Mixin for RESTObject's that can be updated."""
538+
def save(self, **kwargs):
539+
"""Saves the changes made to the object to the server.
540+
541+
Args:
542+
**kwargs: Extra option to send to the server (e.g. sudo)
543+
544+
The object is updated to match what the server returns.
545+
"""
546+
updated_data = {}
547+
required, optional = self.manager.get_update_attrs()
548+
for attr in required:
549+
# Get everything required, no matter if it's been updated
550+
updated_data[attr] = getattr(self, attr)
551+
# Add the updated attributes
552+
updated_data.update(self._updated_attrs)
553+
554+
# class the manager
555+
obj_id = self.get_id()
556+
server_data = self.manager.update(obj_id, updated_data, **kwargs)
557+
self._updated_attrs = {}
558+
self._attrs.update(server_data)
559+
560+
561+
class RESTObject(object):
562+
"""Represents an object built from server data.
563+
564+
It holds the attributes know from te server, and the updated attributes in
565+
another. This allows smart updates, if the object allows it.
566+
567+
You can redefine ``_id_attr`` in child classes to specify which attribute
568+
must be used as uniq ID. None means that the object can be updated without
569+
ID in the url.
570+
"""
571+
_id_attr = 'id'
572+
573+
def __init__(self, manager, attrs):
574+
self.__dict__.update({
575+
'manager': manager,
576+
'_attrs': attrs,
577+
'_updated_attrs': {},
578+
})
579+
580+
def __getattr__(self, name):
581+
try:
582+
return self.__dict__['_updated_attrs'][name]
583+
except KeyError:
584+
try:
585+
return self.__dict__['_attrs'][name]
586+
except KeyError:
587+
raise AttributeError(name)
588+
589+
def __setattr__(self, name, value):
590+
self.__dict__['_updated_attrs'][name] = value
591+
592+
def __str__(self):
593+
data = self._attrs.copy()
594+
data.update(self._updated_attrs)
595+
return '%s => %s' % (type(self), data)
596+
597+
def __repr__(self):
598+
if self._id_attr :
599+
return '<%s %s:%s>' % (self.__class__.__name__,
600+
self._id_attr,
601+
self.get_id())
602+
else:
603+
return '<%s>' % self.__class__.__name__
604+
605+
def get_id(self):
606+
if self._id_attr is None:
607+
return None
608+
return getattr(self, self._id_attr)
609+
610+
611+
class RESTObjectList(object):
612+
"""Generator object representing a list of RESTObject's.
613+
614+
This generator uses the Gitlab pagination system to fetch new data when
615+
required.
616+
617+
Note: you should not instanciate such objects, they are returned by calls
618+
to RESTManager.list()
619+
620+
Args:
621+
manager: Manager to attach to the created objects
622+
obj_cls: Type of objects to create from the json data
623+
_list: A GitlabList object
624+
"""
625+
def __init__(self, manager, obj_cls, _list):
626+
self.manager = manager
627+
self._obj_cls = obj_cls
628+
self._list = _list
629+
630+
def __iter__(self):
631+
return self
632+
633+
def __len__(self):
634+
return len(self._list)
635+
636+
def __next__(self):
637+
return self.next()
638+
639+
def next(self):
640+
data = self._list.next()
641+
return self._obj_cls(self.manager, data)
642+
643+
644+
class GetMixin(object):
645+
def get(self, id, **kwargs):
646+
"""Retrieve a single object.
647+
648+
Args:
649+
id (int or str): ID of the object to retrieve
650+
**kwargs: Extra data to send to the Gitlab server (e.g. sudo)
651+
652+
Returns:
653+
object: The generated RESTObject.
654+
655+
Raises:
656+
GitlabGetError: If the server cannot perform the request.
657+
"""
658+
path = '%s/%s' % (self._path, id)
659+
server_data = self.gitlab.http_get(path, **kwargs)
660+
return self._obj_cls(self, server_data)
661+
662+
663+
class GetWithoutIdMixin(object):
664+
def get(self, **kwargs):
665+
"""Retrieve a single object.
666+
667+
Args:
668+
**kwargs: Extra data to send to the Gitlab server (e.g. sudo)
669+
670+
Returns:
671+
object: The generated RESTObject.
672+
673+
Raises:
674+
GitlabGetError: If the server cannot perform the request.
675+
"""
676+
server_data = self.gitlab.http_get(self._path, **kwargs)
677+
return self._obj_cls(self, server_data)
678+
679+
680+
class ListMixin(object):
681+
def list(self, **kwargs):
682+
"""Retrieves a list of objects.
683+
684+
Args:
685+
**kwargs: Extra data to send to the Gitlab server (e.g. sudo).
686+
If ``all`` is passed and set to True, the entire list of
687+
objects will be returned.
688+
689+
Returns:
690+
RESTObjectList: Generator going through the list of objects, making
691+
queries to the server when required.
692+
If ``all=True`` is passed as argument, returns
693+
list(RESTObjectList).
694+
"""
695+
696+
obj = self.gitlab.http_list(self._path, **kwargs)
697+
if isinstance(obj, list):
698+
return [self._obj_cls(self, item) for item in obj]
699+
else:
700+
return RESTObjectList(self, self._obj_cls, obj)
701+
702+
703+
class GetFromListMixin(ListMixin):
704+
def get(self, id, **kwargs):
705+
"""Retrieve a single object.
706+
707+
Args:
708+
id (int or str): ID of the object to retrieve
709+
**kwargs: Extra data to send to the Gitlab server (e.g. sudo)
710+
711+
Returns:
712+
object: The generated RESTObject.
713+
714+
Raises:
715+
GitlabGetError: If the server cannot perform the request.
716+
"""
717+
gen = self.list()
718+
for obj in gen:
719+
if str(obj.get_id()) == str(id):
720+
return obj
721+
722+
723+
class RetrieveMixin(ListMixin, GetMixin):
724+
pass
725+
726+
727+
class CreateMixin(object):
728+
def _check_missing_attrs(self, data):
729+
required, optional = self.get_create_attrs()
730+
missing = []
731+
for attr in required:
732+
if attr not in data:
733+
missing.append(attr)
734+
continue
735+
if missing:
736+
raise AttributeError("Missing attributes: %s" % ", ".join(missing))
737+
738+
def get_create_attrs(self):
739+
"""Returns the required and optional arguments.
740+
741+
Returns:
742+
tuple: 2 items: list of required arguments and list of optional
743+
arguments for creation (in that order)
744+
"""
745+
if hasattr(self, '_create_attrs'):
746+
return (self._create_attrs['required'],
747+
self._create_attrs['optional'])
748+
return (tuple(), tuple())
749+
750+
def create(self, data, **kwargs):
751+
"""Created a new object.
752+
753+
Args:
754+
data (dict): parameters to send to the server to create the
755+
resource
756+
**kwargs: Extra data to send to the Gitlab server (e.g. sudo)
757+
758+
Returns:
759+
RESTObject: a new instance of the manage object class build with
760+
the data sent by the server
761+
"""
762+
self._check_missing_attrs(data)
763+
if hasattr(self, '_sanitize_data'):
764+
data = self._sanitize_data(data, 'create')
765+
server_data = self.gitlab.http_post(self._path, post_data=data, **kwargs)
766+
return self._obj_cls(self, server_data)
767+
768+
769+
class UpdateMixin(object):
770+
def _check_missing_attrs(self, data):
771+
required, optional = self.get_update_attrs()
772+
missing = []
773+
for attr in required:
774+
if attr not in data:
775+
missing.append(attr)
776+
continue
777+
if missing:
778+
raise AttributeError("Missing attributes: %s" % ", ".join(missing))
779+
780+
def get_update_attrs(self):
781+
"""Returns the required and optional arguments.
782+
783+
Returns:
784+
tuple: 2 items: list of required arguments and list of optional
785+
arguments for update (in that order)
786+
"""
787+
if hasattr(self, '_update_attrs'):
788+
return (self._update_attrs['required'],
789+
self._update_attrs['optional'])
790+
return (tuple(), tuple())
791+
792+
def update(self, id=None, new_data={}, **kwargs):
793+
"""Update an object on the server.
794+
795+
Args:
796+
id: ID of the object to update (can be None if not required)
797+
new_data: the update data for the object
798+
**kwargs: Extra data to send to the Gitlab server (e.g. sudo)
799+
800+
Returns:
801+
dict: The new object data (*not* a RESTObject)
802+
"""
803+
804+
if id is None:
805+
path = self._path
806+
else:
807+
path = '%s/%s' % (self._path, id)
808+
809+
self._check_missing_attrs(new_data)
810+
if hasattr(self, '_sanitize_data'):
811+
data = self._sanitize_data(new_data, 'update')
812+
server_data = self.gitlab.http_put(self._path, post_data=data,
813+
**kwargs)
814+
return server_data
815+
816+
817+
class DeleteMixin(object):
818+
def delete(self, id, **kwargs):
819+
"""Deletes an object on the server.
820+
821+
Args:
822+
id: ID of the object to delete
823+
**kwargs: Extra data to send to the Gitlab server (e.g. sudo)
824+
"""
825+
path = '%s/%s' % (self._path, id)
826+
self.gitlab.http_delete(path, **kwargs)
827+
828+
829+
class CRUDMixin(GetMixin, ListMixin, CreateMixin, UpdateMixin, DeleteMixin):
830+
pass
831+
832+
833+
class RESTManager(object):
834+
"""Base class for CRUD operations on objects.
835+
836+
Derivated class must define ``_path`` and ``_obj_cls``.
837+
838+
``_path``: Base URL path on which requests will be sent (e.g. '/projects')
839+
``_obj_cls``: The class of objects that will be created
840+
"""
841+
842+
_path = None
843+
_obj_cls = None
844+
845+
def __init__(self, gl, parent_attrs={}):
846+
self.gitlab = gl
847+
self._parent_attrs = {} # for nested managers

0 commit comments

Comments
 (0)