diff --git a/AUTHORS b/AUTHORS index 81c476f01..7937908c0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -43,11 +43,13 @@ James E. Flemer James Johnson Jamie Bliss Jason Antman +Jerome Robert Johan Brandhorst Jonathon Reinhart Jon Banafato Koen Smets Kris Gambirazzi +Lyudmil Nenov Mart Sõmermaa massimone88 Matej Zerovnik diff --git a/ChangeLog.rst b/ChangeLog.rst index 7dbdda67b..fe6b2014a 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,6 +1,32 @@ ChangeLog ========= +Version 1.1.0_ - 2017-11-03 +--------------------------- + +* Fix trigger variables in v4 API +* Make the delete() method handle / in ids +* [docs] update the file upload samples +* Tags release description: support / in tag names +* [docs] improve the labels usage documentation +* Add support for listing project users +* ProjectFileManager.create: handle / in file paths +* Change ProjectUser and GroupProject base class +* [docs] document `get_create_attrs` in the API tutorial +* Document the Gitlab session parameter +* ProjectFileManager: custom update() method +* Project: add support for printing_merge_request_link_enabled attr +* Update the ssl_verify docstring +* Add support for group milestones +* Add support for GPG keys +* Add support for wiki pages +* Update the repository_blob documentation +* Fix the CLI for objects without ID (API v4) +* Add a contributed Dockerfile +* Pagination generators: expose more information +* Module's base objects serialization +* [doc] Add sample code for client-side certificates + Version 1.0.2_ - 2017-09-29 --------------------------- @@ -469,6 +495,7 @@ Version 0.1 - 2013-07-08 * Initial release +.. _1.1.0: https://github.com/python-gitlab/python-gitlab/compare/1.0.2...1.1.0 .. _1.0.2: https://github.com/python-gitlab/python-gitlab/compare/1.0.1...1.0.2 .. _1.0.1: https://github.com/python-gitlab/python-gitlab/compare/1.0.0...1.0.1 .. _1.0.0: https://github.com/python-gitlab/python-gitlab/compare/0.21.2...1.0.0 diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index c495cb0ac..44705ee4c 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -4,6 +4,25 @@ Release notes This page describes important changes between python-gitlab releases. +Changes from 1.0.2 to 1.1 +========================= + +* The ``ProjectUser`` class doesn't inherit from ``User`` anymore, and the + ``GroupProject`` class doesn't inherit from ``Project`` anymore. The Gitlab + API doesn't provide the same set of features for these objects, so + python-gitlab objects shouldn't try to workaround that. + + You can create ``User`` or ``Project`` objects from ``ProjectUser`` and + ``GroupProject`` objects using the ``id`` attribute: + + .. code-block:: python + + for gr_project in group.projects.list(): + # lazy object creation avoids a Gitlab API request + project = gl.projects.get(gr_project.id, lazy=True) + project.default_branch = 'develop' + project.save() + Changes from 0.21 to 1.0.0 ========================== diff --git a/contrib/docker/Dockerfile b/contrib/docker/Dockerfile new file mode 100644 index 000000000..6663cac5d --- /dev/null +++ b/contrib/docker/Dockerfile @@ -0,0 +1,10 @@ +FROM python:slim + +# Install python-gitlab +RUN pip install --upgrade python-gitlab + +# Copy sample configuration file +COPY python-gitlab.cfg / + +# Define the entrypoint that enable a configuration file +ENTRYPOINT ["gitlab", "--config-file", "/python-gitlab.cfg"] diff --git a/contrib/docker/README.rst b/contrib/docker/README.rst new file mode 100644 index 000000000..90a576cf4 --- /dev/null +++ b/contrib/docker/README.rst @@ -0,0 +1,19 @@ +python-gitlab docker image +========================== + +Dockerfile contributed by *oupala*: +https://github.com/python-gitlab/python-gitlab/issues/295 + +How to build +------------ + +``docker build -t me/python-gitlab:VERSION .`` + +How to use +---------- + +``docker run -it -v /path/to/python-gitlab.cfg:/python-gitlab.cfg python-gitlab ...`` + +To make things easier you can create a shell alias: + +``alias gitlab='docker run --rm -it -v /path/to/python-gitlab.cfg:/python-gitlab.cfg python-gitlab`` diff --git a/contrib/docker/python-gitlab.cfg b/contrib/docker/python-gitlab.cfg new file mode 100644 index 000000000..0e519545f --- /dev/null +++ b/contrib/docker/python-gitlab.cfg @@ -0,0 +1,15 @@ +[global] +default = somewhere +ssl_verify = true +timeout = 5 +api_version = 3 + +[somewhere] +url = https://some.whe.re +private_token = vTbFeqJYCY3sibBP7BZM +api_version = 4 + +[elsewhere] +url = http://else.whe.re:8080 +private_token = CkqsjqcQSFH5FQKDccu4 +timeout = 1 diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 4b40ce17b..e549924c2 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -30,3 +30,4 @@ API examples gl_objects/todos gl_objects/users gl_objects/sidekiq + gl_objects/wikis diff --git a/docs/api-usage.rst b/docs/api-usage.rst index ecb0e645f..edd41d010 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -7,32 +7,7 @@ python-gitlab supports both GitLab v3 and v4 APIs. v3 being deprecated by GitLab, its support in python-gitlab will be minimal. The development team will focus on v4. -v3 is still the default API used by python-gitlab, for compatibility reasons.. - - -Base types -========== - -The ``gitlab`` package provides some base types. - -* ``gitlab.Gitlab`` is the primary class, handling the HTTP requests. It holds - the GitLab URL and authentication information. - -For v4 the following types are defined: - -* ``gitlab.base.RESTObject`` is the base class for all the GitLab v4 objects. - These objects provide an abstraction for GitLab resources (projects, groups, - and so on). -* ``gitlab.base.RESTManager`` is the base class for v4 objects managers, - providing the API to manipulate the resources and their attributes. - -For v3 the following types are defined: - -* ``gitlab.base.GitlabObject`` is the base class for all the GitLab v3 objects. - These objects provide an abstraction for GitLab resources (projects, groups, - and so on). -* ``gitlab.base.BaseManager`` is the base class for v3 objects managers, - providing the API to manipulate the resources and their attributes. +v3 is still the default API used by python-gitlab, for compatibility reasons. ``gitlab.Gitlab`` class @@ -109,6 +84,17 @@ Examples: user = gl.users.create(user_data) print(user) +You can list the mandatory and optional attributes for object creation +with the manager's ``get_create_attrs()`` method. It returns 2 tuples, the +first one is the list of mandatory attributes, the second one the list of +optional attribute: + +.. code-block:: python + + # v4 only + print(gl.projects.get_create_attrs()) + (('name',), ('path', 'namespace_id', ...)) + The attributes of objects are defined upon object creation, and depend on the GitLab API itself. To list the available information associated with an object use the python introspection tools for v3, or the ``attributes`` attribute for @@ -150,7 +136,6 @@ You can update or delete a remote object when it exists locally: # delete the resource project.delete() - Some classes provide additional methods, allowing more actions on the GitLab resources. For example: @@ -160,6 +145,30 @@ resources. For example: project = gl.projects.get(1) project.star() +Base types +========== + +The ``gitlab`` package provides some base types. + +* ``gitlab.Gitlab`` is the primary class, handling the HTTP requests. It holds + the GitLab URL and authentication information. + +For v4 the following types are defined: + +* ``gitlab.base.RESTObject`` is the base class for all the GitLab v4 objects. + These objects provide an abstraction for GitLab resources (projects, groups, + and so on). +* ``gitlab.base.RESTManager`` is the base class for v4 objects managers, + providing the API to manipulate the resources and their attributes. + +For v3 the following types are defined: + +* ``gitlab.base.GitlabObject`` is the base class for all the GitLab v3 objects. + These objects provide an abstraction for GitLab resources (projects, groups, + and so on). +* ``gitlab.base.BaseManager`` is the base class for v3 objects managers, + providing the API to manipulate the resources and their attributes. + Lazy objects (v4 only) ====================== @@ -216,6 +225,15 @@ handle the next calls to the API when required: for item in items: print(item.attributes) +The generator exposes extra listing information as received by the server: + +* ``current_page``: current page number (first page is 1) +* ``prev_page``: if ``None`` the current page is the first one +* ``next_page``: if ``None`` the current page is the last one +* ``per_page``: number of items per page +* ``total_pages``: total number of pages available +* ``total``: total number of items in the list + Sudo ==== @@ -225,3 +243,50 @@ user. For example: .. code-block:: python p = gl.projects.create({'name': 'awesome_project'}, sudo='user1') + +Advanced HTTP configuration +=========================== + +python-gitlab relies on ``requests`` ``Session`` objects to perform all the +HTTP requests to the Gitlab servers. + +You can provide your own ``Session`` object with custom configuration when +you create a ``Gitlab`` object. + +Proxy configuration +------------------- + +The following sample illustrates how to define a proxy configuration when using +python-gitlab: + +.. code-block:: python + + import gitlab + import requests + + session = requests.Session() + session.proxies = { + 'https': os.environ.get('https_proxy'), + 'http': os.environ.get('http_proxy'), + } + gl = gitlab.gitlab(url, token, api_version=4, session=session) + +Reference: +http://docs.python-requests.org/en/master/user/advanced/#proxies + +Client side certificate +----------------------- + +The following sample illustrates how to use a client-side certificate: + +.. code-block:: python + + import gitlab + import requests + + session = requests.Session() + s.cert = ('/path/to/client.cert', '/path/to/client.key') + gl = gitlab.gitlab(url, token, api_version=4, session=session) + +Reference: +http://docs.python-requests.org/en/master/user/advanced/#client-side-certificates diff --git a/docs/gl_objects/labels.py b/docs/gl_objects/labels.py index 57892b5d1..a63e295f5 100644 --- a/docs/gl_objects/labels.py +++ b/docs/gl_objects/labels.py @@ -24,3 +24,13 @@ # or label.delete() # end delete + +# use +# Labels are defined as lists in issues and merge requests. The labels must +# exist. +issue = p.issues.create({'title': 'issue title', + 'description': 'issue description', + 'labels': ['foo']}) +issue.labels.append('bar') +issue.save() +# end use diff --git a/docs/gl_objects/labels.rst b/docs/gl_objects/labels.rst index d44421723..3c8034d77 100644 --- a/docs/gl_objects/labels.rst +++ b/docs/gl_objects/labels.rst @@ -52,3 +52,9 @@ Delete a label for a project: .. literalinclude:: labels.py :start-after: # delete :end-before: # end delete + +Managing labels in issues and merge requests: + +.. literalinclude:: labels.py + :start-after: # use + :end-before: # end use diff --git a/docs/gl_objects/milestones.py b/docs/gl_objects/milestones.py index 19770bcf1..d1985d969 100644 --- a/docs/gl_objects/milestones.py +++ b/docs/gl_objects/milestones.py @@ -1,13 +1,16 @@ # list -milestones = project.milestones.list() +p_milestones = project.milestones.list() +g_milestones = group.milestones.list() # end list # filter -milestones = project.milestones.list(state='closed') +p_milestones = project.milestones.list(state='closed') +g_milestones = group.milestones.list(state='active') # end filter # get -milestone = project.milestones.get(milestone_id) +p_milestone = project.milestones.get(milestone_id) +g_milestone = group.milestones.get(milestone_id) # end get # create @@ -36,4 +39,3 @@ # merge_requests merge_requests = milestone.merge_requests() # end merge_requests - diff --git a/docs/gl_objects/milestones.rst b/docs/gl_objects/milestones.rst index fbe5d879c..c96560a89 100644 --- a/docs/gl_objects/milestones.rst +++ b/docs/gl_objects/milestones.rst @@ -11,6 +11,10 @@ Reference + :class:`gitlab.v4.objects.ProjectMilestoneManager` + :attr:`gitlab.v4.objects.Project.milestones` + + :class:`gitlab.v4.objects.GroupMilestone` + + :class:`gitlab.v4.objects.GroupMilestoneManager` + + :attr:`gitlab.v4.objects.Group.milestones` + * v3 API: + :class:`gitlab.v3.objects.ProjectMilestone` @@ -18,12 +22,15 @@ Reference + :attr:`gitlab.v3.objects.Project.milestones` + :attr:`gitlab.Gitlab.project_milestones` -* GitLab API: https://docs.gitlab.com/ce/api/milestones.html +* GitLab API: + + + https://docs.gitlab.com/ce/api/milestones.html + + https://docs.gitlab.com/ce/api/group_milestones.html Examples -------- -List the milestones for a project: +List the milestones for a project or a group: .. literalinclude:: milestones.py :start-after: # list @@ -33,6 +40,7 @@ You can filter the list using the following parameters: * ``iid``: unique ID of the milestone for the project * ``state``: either ``active`` or ``closed`` +* ``search``: to search using a string .. literalinclude:: milestones.py :start-after: # filter diff --git a/docs/gl_objects/mrs.py b/docs/gl_objects/mrs.py index bc30b4342..1e54c80bb 100644 --- a/docs/gl_objects/mrs.py +++ b/docs/gl_objects/mrs.py @@ -13,7 +13,8 @@ # create mr = project.mergerequests.create({'source_branch': 'cool_feature', 'target_branch': 'master', - 'title': 'merge cool feature'}) + 'title': 'merge cool feature', + 'labels': ['label1', 'label2']}) # end create # update diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index f0a4d1a66..4a6f3ad37 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -142,7 +142,10 @@ # end repository tree # repository blob -file_content = p.repository_blob('master', 'README.rst') +items = project.repository_tree(path='docs', ref='branch1') +file_info = p.repository_blob(items[0]['id']) +content = base64.b64decode(file_info['content']) +size = file_info['size'] # end repository blob # repository raw_blob @@ -379,17 +382,24 @@ # end project file upload with data # project file upload markdown -uploaded_file = project.upload_file("filename.txt", filedata="data") +uploaded_file = project.upload("filename.txt", filedata="data") issue = project.issues.get(issue_id) issue.notes.create({ "body": "See the attached file: {}".format(uploaded_file["markdown"]) }) -# project file upload markdown +# end project file upload markdown # project file upload markdown custom -uploaded_file = project.upload_file("filename.txt", filedata="data") +uploaded_file = project.upload("filename.txt", filedata="data") issue = project.issues.get(issue_id) issue.notes.create({ "body": "See the [attached file]({})".format(uploaded_file["url"]) }) -# project file upload markdown +# end project file upload markdown custom + +# users list +users = p.users.list() + +# search for users +users = p.users.list(search='pattern') +# end users list diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index b6cf311c5..eb15a3bf1 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -120,7 +120,7 @@ List the repository tree: :start-after: # repository tree :end-before: # end repository tree -Get the content of a file for a commit: +Get the content and metadata of a file for a commit, using a blob sha: .. literalinclude:: projects.py :start-after: # repository blob @@ -160,6 +160,12 @@ Get a list of contributors for the repository: :start-after: # repository contributors :end-before: # end repository contributors +Get a list of users for the repository: + +.. literalinclude:: projects.py + :start-after: # users list + :end-before: # end users list + Project files ============= @@ -781,7 +787,7 @@ Delete a list: :end-before: # end board lists delete -File Uploads +File uploads ============ Reference @@ -790,12 +796,10 @@ Reference * v4 API: + :attr:`gitlab.v4.objects.Project.upload` - + :class:`gitlab.v4.objects.ProjectUpload` * v3 API: + :attr:`gitlab.v3.objects.Project.upload` - + :class:`gitlab.v3.objects.ProjectUpload` * Gitlab API: https://docs.gitlab.com/ce/api/projects.html#upload-a-file diff --git a/docs/gl_objects/users.py b/docs/gl_objects/users.py index 798678d13..c3618b988 100644 --- a/docs/gl_objects/users.py +++ b/docs/gl_objects/users.py @@ -36,37 +36,44 @@ # end block # key list -keys = gl.user_keys.list(user_id=1) -# or keys = user.keys.list() # end key list # key get -key = gl.user_keys.list(1, user_id=1) -# or key = user.keys.get(1) # end key get # key create -k = gl.user_keys.create({'title': 'my_key', - 'key': open('/home/me/.ssh/id_rsa.pub').read()}, - user_id=2) -# or k = user.keys.create({'title': 'my_key', 'key': open('/home/me/.ssh/id_rsa.pub').read()}) # end key create # key delete -gl.user_keys.delete(1, user_id=1) -# or user.keys.delete(1) # or key.delete() # end key delete -# email list -emails = gl.user_emails.list(user_id=1) +# gpgkey list +gpgkeys = user.gpgkeys.list() +# end gpgkey list + +# gpgkey get +gpgkey = user.gpgkeys.get(1) +# end gpgkey get + +# gpgkey create +# get the key with `gpg --export -a GPG_KEY_ID` +k = user.gpgkeys.create({'key': public_key_content}) +# end gpgkey create + +# gpgkey delete +user.gpgkeys.delete(1) # or +gpgkey.delete() +# end gpgkey delete + +# email list emails = user.emails.list() # end email list @@ -77,14 +84,10 @@ # end email get # email create -k = gl.user_emails.create({'email': 'foo@bar.com'}, user_id=2) -# or k = user.emails.create({'email': 'foo@bar.com'}) # end email create # email delete -gl.user_emails.delete(1, user_id=1) -# or user.emails.delete(1) # or email.delete() @@ -94,39 +97,3 @@ gl.auth() current_user = gl.user # end currentuser get - -# currentuser key list -keys = gl.user.keys.list() -# end currentuser key list - -# currentuser key get -key = gl.user.keys.get(1) -# end currentuser key get - -# currentuser key create -key = gl.user.keys.create({'id': 'my_key', 'key': key_content}) -# end currentuser key create - -# currentuser key delete -gl.user.keys.delete(1) -# or -key.delete() -# end currentuser key delete - -# currentuser email list -emails = gl.user.emails.list() -# end currentuser email list - -# currentuser email get -email = gl.user.emails.get(1) -# end currentuser email get - -# currentuser email create -email = gl.user.emails.create({'email': 'foo@bar.com'}) -# end currentuser email create - -# currentuser email delete -gl.user.emails.delete(1) -# or -email.delete() -# end currentuser email delete diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst index 8df93b03f..d5b29764d 100644 --- a/docs/gl_objects/users.rst +++ b/docs/gl_objects/users.rst @@ -1,14 +1,32 @@ -##### +###################### +Users and current user +###################### + +The Gitlab API exposes user-related method that can be manipulated by admins +only. + +The currently logged-in user is also exposed. + Users -##### +===== + +References +---------- + +* v4 API: + + + :class:`gitlab.v4.objects.User` + + :class:`gitlab.v4.objects.UserManager` + + :attr:`gitlab.Gitlab.users` -Use :class:`~gitlab.objects.User` objects to manipulate repository branches. +* v3 API: -To create :class:`~gitlab.objects.User` objects use the -:attr:`gitlab.Gitlab.users` manager. + + :class:`gitlab.v3.objects.User` + + :class:`gitlab.v3.objects.UserManager` + + :attr:`gitlab.Gitlab.users` Examples -======== +-------- Get the list of users: @@ -52,14 +70,97 @@ Block/Unblock a user: :start-after: # block :end-before: # end block +Current User +============ + +* v4 API: + + + :class:`gitlab.v4.objects.CurrentUser` + + :class:`gitlab.v4.objects.CurrentUserManager` + + :attr:`gitlab.Gitlab.user` + +* v3 API: + + + :class:`gitlab.v3.objects.CurrentUser` + + :class:`gitlab.v3.objects.CurrentUserManager` + + :attr:`gitlab.Gitlab.user` + +Examples +-------- + +Get the current user: + +.. literalinclude:: users.py + :start-after: # currentuser get + :end-before: # end currentuser get + +GPG keys +======== + +You can manipulate GPG keys for the current user and for the other users if you +are admin. + +* v4 API: + + + :class:`gitlab.v4.objects.CurrentUserGPGKey` + + :class:`gitlab.v4.objects.CurrentUserGPGKeyManager` + + :attr:`gitlab.v4.objects.CurrentUser.gpgkeys` + + :class:`gitlab.v4.objects.UserGPGKey` + + :class:`gitlab.v4.objects.UserGPGKeyManager` + + :attr:`gitlab.v4.objects.User.gpgkeys` + +Exemples +-------- + +List GPG keys for a user: + +.. literalinclude:: users.py + :start-after: # gpgkey list + :end-before: # end gpgkey list + +Get an GPG gpgkey for a user: + +.. literalinclude:: users.py + :start-after: # gpgkey get + :end-before: # end gpgkey get + +Create an GPG gpgkey for a user: + +.. literalinclude:: users.py + :start-after: # gpgkey create + :end-before: # end gpgkey create + +Delete an GPG gpgkey for a user: + +.. literalinclude:: users.py + :start-after: # gpgkey delete + :end-before: # end gpgkey delete + SSH keys ======== -Use the :class:`~gitlab.objects.UserKey` objects to manage user keys. +You can manipulate SSH keys for the current user and for the other users if you +are admin. + +* v4 API: + + + :class:`gitlab.v4.objects.CurrentUserKey` + + :class:`gitlab.v4.objects.CurrentUserKeyManager` + + :attr:`gitlab.v4.objects.CurrentUser.keys` + + :class:`gitlab.v4.objects.UserKey` + + :class:`gitlab.v4.objects.UserKeyManager` + + :attr:`gitlab.v4.objects.User.keys` -To create :class:`~gitlab.objects.UserKey` objects use the -:attr:`User.keys ` or :attr:`gitlab.Gitlab.user_keys` -managers. +* v3 API: + + + :class:`gitlab.v3.objects.CurrentUserKey` + + :class:`gitlab.v3.objects.CurrentUserKeyManager` + + :attr:`gitlab.v3.objects.CurrentUser.keys` + + :attr:`gitlab.Gitlab.user.keys` + + :class:`gitlab.v3.objects.UserKey` + + :class:`gitlab.v3.objects.UserKeyManager` + + :attr:`gitlab.v3.objects.User.keys` + + :attr:`gitlab.Gitlab.user_keys` Exemples -------- @@ -91,10 +192,28 @@ Delete an SSH key for a user: Emails ====== -Use the :class:`~gitlab.objects.UserEmail` objects to manage user emails. +You can manipulate emails for the current user and for the other users if you +are admin. + +* v4 API: + + + :class:`gitlab.v4.objects.CurrentUserEmail` + + :class:`gitlab.v4.objects.CurrentUserEmailManager` + + :attr:`gitlab.v4.objects.CurrentUser.emails` + + :class:`gitlab.v4.objects.UserEmail` + + :class:`gitlab.v4.objects.UserEmailManager` + + :attr:`gitlab.v4.objects.User.emails` -To create :class:`~gitlab.objects.UserEmail` objects use the :attr:`User.emails -` or :attr:`gitlab.Gitlab.user_emails` managers. +* v3 API: + + + :class:`gitlab.v3.objects.CurrentUserEmail` + + :class:`gitlab.v3.objects.CurrentUserEmailManager` + + :attr:`gitlab.v3.objects.CurrentUser.emails` + + :attr:`gitlab.Gitlab.user.emails` + + :class:`gitlab.v3.objects.UserEmail` + + :class:`gitlab.v3.objects.UserEmailManager` + + :attr:`gitlab.v3.objects.User.emails` + + :attr:`gitlab.Gitlab.user_emails` Exemples -------- @@ -122,76 +241,3 @@ Delete an email for a user: .. literalinclude:: users.py :start-after: # email delete :end-before: # end email delete - -Current User -============ - -Use the :class:`~gitlab.objects.CurrentUser` object to get information about -the currently logged-in user. - -Use the :class:`~gitlab.objects.CurrentUserKey` objects to manage user keys. - -To create :class:`~gitlab.objects.CurrentUserKey` objects use the -:attr:`gitlab.objects.CurrentUser.keys ` manager. - -Use the :class:`~gitlab.objects.CurrentUserEmail` objects to manage user emails. - -To create :class:`~gitlab.objects.CurrentUserEmail` objects use the -:attr:`gitlab.objects.CurrentUser.emails ` manager. - -Examples --------- - -Get the current user: - -.. literalinclude:: users.py - :start-after: # currentuser get - :end-before: # end currentuser get - -List the current user SSH keys: - -.. literalinclude:: users.py - :start-after: # currentuser key list - :end-before: # end currentuser key list - -Get a key for the current user: - -.. literalinclude:: users.py - :start-after: # currentuser key get - :end-before: # end currentuser key get - -Create a key for the current user: - -.. literalinclude:: users.py - :start-after: # currentuser key create - :end-before: # end currentuser key create - -Delete a key for the current user: - -.. literalinclude:: users.py - :start-after: # currentuser key delete - :end-before: # end currentuser key delete - -List the current user emails: - -.. literalinclude:: users.py - :start-after: # currentuser email list - :end-before: # end currentuser email list - -Get an email for the current user: - -.. literalinclude:: users.py - :start-after: # currentuser email get - :end-before: # end currentuser email get - -Create an email for the current user: - -.. literalinclude:: users.py - :start-after: # currentuser email create - :end-before: # end currentuser email create - -Delete an email for the current user: - -.. literalinclude:: users.py - :start-after: # currentuser email delete - :end-before: # end currentuser email delete diff --git a/docs/gl_objects/wikis.py b/docs/gl_objects/wikis.py new file mode 100644 index 000000000..0c92fe6d5 --- /dev/null +++ b/docs/gl_objects/wikis.py @@ -0,0 +1,21 @@ +# list +pages = project.wikis.list() +# end list + +# get +page = project.wikis.get(page_slug) +# end get + +# create +page = project.wikis.create({'title': 'Wiki Page 1', + 'content': open(a_file).read()}) +# end create + +# update +page.content = 'My new content' +page.save() +# end update + +# delete +page.delete() +# end delete diff --git a/docs/gl_objects/wikis.rst b/docs/gl_objects/wikis.rst new file mode 100644 index 000000000..0934654f7 --- /dev/null +++ b/docs/gl_objects/wikis.rst @@ -0,0 +1,46 @@ +########## +Wiki pages +########## + + +References +========== + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectWiki` + + :class:`gitlab.v4.objects.ProjectWikiManager` + + :attr:`gitlab.v4.objects.Project.wikis` + +Examples +-------- + +Get the list of wiki pages for a project: + +.. literalinclude:: wikis.py + :start-after: # list + :end-before: # end list + +Get a single wiki page: + +.. literalinclude:: wikis.py + :start-after: # get + :end-before: # end get + +Create a wiki page: + +.. literalinclude:: wikis.py + :start-after: # create + :end-before: # end create + +Update a wiki page: + +.. literalinclude:: wikis.py + :start-after: # update + :end-before: # end update + +Delete a wiki page: + +.. literalinclude:: wikis.py + :start-after: # delete + :end-before: # end delete diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 25b0b866c..d5b480be6 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -34,7 +34,7 @@ from gitlab.v3.objects import * # noqa __title__ = 'python-gitlab' -__version__ = '1.0.2' +__version__ = '1.1.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' @@ -61,7 +61,9 @@ class Gitlab(object): private_token (str): The user private token email (str): The user email or login. password (str): The user password (associated with email). - ssl_verify (bool): Whether SSL certificates should be validated. + ssl_verify (bool|str): Whether SSL certificates should be validated. If + the value is a string, it is the path to a CA file used for + certificate validation. timeout (float): Timeout to use for requests to the GitLab server. http_username (str): Username for HTTP authentication http_password (str): Password for HTTP authentication @@ -136,6 +138,17 @@ def __init__(self, url, private_token=None, email=None, password=None, manager = getattr(objects, cls_name)(self) setattr(self, var_name, manager) + def __getstate__(self): + state = self.__dict__.copy() + state.pop('_objects') + return state + + def __setstate__(self, state): + self.__dict__.update(state) + objects = importlib.import_module('gitlab.v%s.objects' % + self._api_version) + self._objects = objects + @property def api_version(self): return self._api_version @@ -862,6 +875,7 @@ def _query(self, url, query_data={}, **kwargs): except KeyError: self._next_url = None self._current_page = result.headers.get('X-Page') + self._prev_page = result.headers.get('X-Prev-Page') self._next_page = result.headers.get('X-Next-Page') self._per_page = result.headers.get('X-Per-Page') self._total_pages = result.headers.get('X-Total-Pages') @@ -875,6 +889,42 @@ def _query(self, url, query_data={}, **kwargs): self._current = 0 + @property + def current_page(self): + """The current page number.""" + return int(self._current_page) + + @property + def prev_page(self): + """The next page number. + + If None, the current page is the last. + """ + return int(self._prev_page) if self._prev_page else None + + @property + def next_page(self): + """The next page number. + + If None, the current page is the last. + """ + return int(self._next_page) if self._next_page else None + + @property + def per_page(self): + """The number of items per page.""" + return int(self._per_page) + + @property + def total_pages(self): + """The total number of pages.""" + return int(self._total_pages) + + @property + def total(self): + """The total number of items.""" + return int(self._total) + def __iter__(self): return self diff --git a/gitlab/base.py b/gitlab/base.py index ccc9e4a24..ec5f6987a 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -416,6 +416,17 @@ def __init__(self, gl, data=None, **kwargs): if not hasattr(self, "id"): self.id = None + def __getstate__(self): + state = self.__dict__.copy() + module = state.pop('_module') + state['_module_name'] = module.__name__ + return state + + def __setstate__(self, state): + module_name = state.pop('_module_name') + self.__dict__.update(state) + self._module = importlib.import_module(module_name) + def _set_manager(self, var, cls, attrs): manager = cls(self.gitlab, self, attrs) setattr(self, var, manager) @@ -553,11 +564,18 @@ def __init__(self, manager, attrs): '_module': importlib.import_module(self.__module__) }) self.__dict__['_parent_attrs'] = self.manager.parent_attrs + self._create_managers() - # TODO(gpocentek): manage the creation of new objects from the received - # data (_constructor_types) + def __getstate__(self): + state = self.__dict__.copy() + module = state.pop('_module') + state['_module_name'] = module.__name__ + return state - self._create_managers() + def __setstate__(self, state): + module_name = state.pop('_module_name') + self.__dict__.update(state) + self._module = importlib.import_module(module_name) def __getattr__(self, name): try: @@ -674,6 +692,42 @@ def next(self): data = self._list.next() return self._obj_cls(self.manager, data) + @property + def current_page(self): + """The current page number.""" + return self._list.current_page + + @property + def prev_page(self): + """The next page number. + + If None, the current page is the last. + """ + return self._list.prev_page + + @property + def next_page(self): + """The next page number. + + If None, the current page is the last. + """ + return self._list.next_page + + @property + def per_page(self): + """The number of items per page.""" + return self._list.per_page + + @property + def total_pages(self): + """The total number of pages.""" + return self._list.total_pages + + @property + def total(self): + """The total number of items.""" + return self._list.total + class RESTManager(object): """Base class for CRUD operations on objects. diff --git a/gitlab/mixins.py b/gitlab/mixins.py index aa529897b..d01715284 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -51,7 +51,7 @@ def get(self, id, lazy=False, **kwargs): class GetWithoutIdMixin(object): @exc.on_http_error(exc.GitlabGetError) - def get(self, **kwargs): + def get(self, id=None, **kwargs): """Retrieve a single object. Args: @@ -235,6 +235,8 @@ def delete(self, id, **kwargs): GitlabAuthenticationError: If authentication is not correct GitlabDeleteError: If the server cannot perform the request """ + if not isinstance(id, int): + id = id.replace('/', '%2F') path = '%s/%s' % (self.path, id) self.gitlab.http_delete(path, **kwargs) diff --git a/gitlab/tests/test_base.py b/gitlab/tests/test_base.py index c55f0003c..31dd96771 100644 --- a/gitlab/tests/test_base.py +++ b/gitlab/tests/test_base.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +import pickle try: import unittest except ImportError: @@ -86,6 +87,15 @@ def test_instanciate(self): self.assertEqual(self.manager, obj.manager) self.assertEqual(self.gitlab, obj.manager.gitlab) + def test_pickability(self): + obj = FakeObject(self.manager, {'foo': 'bar'}) + original_obj_module = obj._module + pickled = pickle.dumps(obj) + unpickled = pickle.loads(pickled) + self.assertIsInstance(unpickled, FakeObject) + self.assertTrue(hasattr(unpickled, '_module')) + self.assertEqual(unpickled._module, original_obj_module) + def test_attrs(self): obj = FakeObject(self.manager, {'foo': 'bar'}) diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index 6bc427df7..027de0c02 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -18,6 +18,7 @@ from __future__ import print_function +import pickle try: import unittest except ImportError: @@ -209,6 +210,12 @@ def resp_2(url, request): self.assertEqual(len(obj), 2) self.assertEqual(obj._next_url, 'http://localhost/api/v4/tests?per_page=1&page=2') + self.assertEqual(obj.current_page, 1) + self.assertEqual(obj.prev_page, None) + self.assertEqual(obj.next_page, 2) + self.assertEqual(obj.per_page, 1) + self.assertEqual(obj.total_pages, 2) + self.assertEqual(obj.total, 2) with HTTMock(resp_2): l = list(obj) @@ -884,6 +891,14 @@ def setUp(self): email="testuser@test.com", password="testpassword", ssl_verify=True) + def test_pickability(self): + original_gl_objects = self.gl._objects + pickled = pickle.dumps(self.gl) + unpickled = pickle.loads(pickled) + self.assertIsInstance(unpickled, Gitlab) + self.assertTrue(hasattr(unpickled, '_objects')) + self.assertEqual(unpickled._objects, original_gl_objects) + def test_credentials_auth_nopassword(self): self.gl.email = None self.gl.password = None diff --git a/gitlab/tests/test_gitlabobject.py b/gitlab/tests/test_gitlabobject.py index 695f900d8..f7fd1872f 100644 --- a/gitlab/tests/test_gitlabobject.py +++ b/gitlab/tests/test_gitlabobject.py @@ -21,6 +21,7 @@ from __future__ import absolute_import import json +import pickle try: import unittest except ImportError: @@ -158,6 +159,15 @@ def test_json(self): self.assertEqual(data["username"], "testname") self.assertEqual(data["gitlab"]["url"], "http://localhost/api/v3") + def test_pickability(self): + gl_object = CurrentUser(self.gl, data={"username": "testname"}) + original_obj_module = gl_object._module + pickled = pickle.dumps(gl_object) + unpickled = pickle.loads(pickled) + self.assertIsInstance(unpickled, CurrentUser) + self.assertTrue(hasattr(unpickled, '_module')) + self.assertEqual(unpickled._module, original_obj_module) + def test_data_for_gitlab(self): class FakeObj1(GitlabObject): _url = '/fake1' diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index 6e664b392..939a7ccb6 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -83,7 +83,7 @@ def do_list(self): def do_get(self): id = None - if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(self.cls): + if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(self.mgr_cls): id = self.args.pop(self.cls._id_attr) try: @@ -99,7 +99,9 @@ def do_delete(self): cli.die("Impossible to destroy object", e) def do_update(self): - id = self.args.pop(self.cls._id_attr) + id = None + if gitlab.mixins.GetWithoutIdMixin not in inspect.getmro(self.mgr_cls): + id = self.args.pop(self.cls._id_attr) try: return self.mgr.update(id, self.args) except Exception as e: @@ -282,15 +284,18 @@ def display_dict(d, padding): return # not a dict, we assume it's a RESTObject - id = getattr(obj, obj._id_attr, None) - print('%s: %s' % (obj._id_attr, id)) + if obj._id_attr: + id = getattr(obj, obj._id_attr, None) + print('%s: %s' % (obj._id_attr, id)) attrs = obj.attributes - attrs.pop(obj._id_attr) + if obj._id_attr: + attrs.pop(obj._id_attr) display_dict(attrs, padding) else: - id = getattr(obj, obj._id_attr) - print('%s: %s' % (obj._id_attr.replace('_', '-'), id)) + if obj._id_attr: + id = getattr(obj, obj._id_attr) + print('%s: %s' % (obj._id_attr.replace('_', '-'), id)) if hasattr(obj, '_short_print_attr'): value = getattr(obj, obj._short_print_attr) print('%s: %s' % (obj._short_print_attr, value)) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 4bd5aada0..5a3f17c42 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -19,8 +19,6 @@ from __future__ import absolute_import import base64 -import six - from gitlab.base import * # noqa from gitlab import cli from gitlab.exceptions import * # noqa @@ -125,6 +123,17 @@ class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _create_attrs = (('email', ), tuple()) +class UserGPGKey(ObjectDeleteMixin, RESTObject): + pass + + +class UserGPGKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): + _path = '/users/%(user_id)s/gpg_keys' + _obj_cls = UserGPGKey + _from_parent_attrs = {'user_id': 'id'} + _create_attrs = (('key',), tuple()) + + class UserKey(ObjectDeleteMixin, RESTObject): pass @@ -137,7 +146,7 @@ class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager): class UserProject(RESTObject): - _constructor_types = {'owner': 'User', 'namespace': 'Group'} + pass class UserProjectManager(CreateMixin, RESTManager): @@ -157,6 +166,7 @@ class User(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'username' _managers = ( ('emails', 'UserEmailManager'), + ('gpgkeys', 'UserGPGKeyManager'), ('keys', 'UserKeyManager'), ('projects', 'UserProjectManager'), ) @@ -243,6 +253,17 @@ class CurrentUserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, _create_attrs = (('email', ), tuple()) +class CurrentUserGPGKey(ObjectDeleteMixin, RESTObject): + pass + + +class CurrentUserGPGKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/user/gpg_keys' + _obj_cls = CurrentUserGPGKey + _create_attrs = (('key',), tuple()) + + class CurrentUserKey(ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' @@ -259,6 +280,7 @@ class CurrentUser(RESTObject): _short_print_attr = 'username' _managers = ( ('emails', 'CurrentUserEmailManager'), + ('gpgkeys', 'CurrentUserGPGKeyManager'), ('keys', 'CurrentUserKeyManager'), ) @@ -362,6 +384,17 @@ class GitlabciymlManager(RetrieveMixin, RESTManager): _obj_cls = Gitlabciyml +class GroupAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject): + pass + + +class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, + RESTManager): + _path = '/groups/%(group_id)s/access_requests' + _obj_cls = GroupAccessRequest + _from_parent_attrs = {'group_id': 'id'} + + class GroupIssue(RESTObject): pass @@ -386,6 +419,75 @@ class GroupMemberManager(GetFromListMixin, CreateMixin, UpdateMixin, _update_attrs = (('access_level', ), ('expires_at', )) +class GroupMergeRequest(RESTObject): + pass + + +class GroupMergeRequestManager(RESTManager): + pass + + +class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): + _short_print_attr = 'title' + + @cli.register_custom_action('GroupMilestone') + @exc.on_http_error(exc.GitlabListError) + def issues(self, **kwargs): + """List issues related to this milestone. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + RESTObjectList: The list of issues + """ + + path = '%s/%s/issues' % (self.manager.path, self.get_id()) + data_list = self.manager.gitlab.http_list(path, as_list=False, + **kwargs) + manager = GroupIssueManager(self.manager.gitlab, + parent=self.manager._parent) + # FIXME(gpocentek): the computed manager path is not correct + return RESTObjectList(manager, GroupIssue, data_list) + + @cli.register_custom_action('GroupMilestone') + @exc.on_http_error(exc.GitlabListError) + def merge_requests(self, **kwargs): + """List the merge requests related to this milestone. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + RESTObjectList: The list of merge requests + """ + path = '%s/%s/merge_requests' % (self.manager.path, self.get_id()) + data_list = self.manager.gitlab.http_list(path, as_list=False, + **kwargs) + manager = GroupIssueManager(self.manager.gitlab, + parent=self.manager._parent) + # FIXME(gpocentek): the computed manager path is not correct + return RESTObjectList(manager, GroupMergeRequest, data_list) + + +class GroupMilestoneManager(CRUDMixin, RESTManager): + _path = '/groups/%(group_id)s/milestones' + _obj_cls = GroupMilestone + _from_parent_attrs = {'group_id': 'id'} + _create_attrs = (('title', ), ('description', 'due_date', 'start_date')) + _update_attrs = (tuple(), ('title', 'description', 'due_date', + 'start_date', 'state_event')) + _list_filters = ('iids', 'state', 'search') + + class GroupNotificationSettings(NotificationSettings): pass @@ -396,15 +498,72 @@ class GroupNotificationSettingsManager(NotificationSettingsManager): _from_parent_attrs = {'group_id': 'id'} -class GroupAccessRequest(AccessRequestMixin, ObjectDeleteMixin, RESTObject): +class GroupProject(RESTObject): pass -class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, - RESTManager): - _path = '/groups/%(group_id)s/access_requests' - _obj_cls = GroupAccessRequest +class GroupProjectManager(GetFromListMixin, RESTManager): + _path = '/groups/%(group_id)s/projects' + _obj_cls = GroupProject + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('archived', 'visibility', 'order_by', 'sort', 'search', + 'ci_enabled_first') + + +class GroupVariable(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = 'key' + + +class GroupVariableManager(CRUDMixin, RESTManager): + _path = '/groups/%(group_id)s/variables' + _obj_cls = GroupVariable _from_parent_attrs = {'group_id': 'id'} + _create_attrs = (('key', 'value'), ('protected',)) + _update_attrs = (('key', 'value'), ('protected',)) + + +class Group(SaveMixin, ObjectDeleteMixin, RESTObject): + _short_print_attr = 'name' + _managers = ( + ('accessrequests', 'GroupAccessRequestManager'), + ('members', 'GroupMemberManager'), + ('milestones', 'GroupMilestoneManager'), + ('notificationsettings', 'GroupNotificationSettingsManager'), + ('projects', 'GroupProjectManager'), + ('issues', 'GroupIssueManager'), + ('variables', 'GroupVariableManager'), + ) + + @cli.register_custom_action('Group', ('to_project_id', )) + @exc.on_http_error(exc.GitlabTransferProjectError) + def transfer_project(self, to_project_id, **kwargs): + """Transfer a project to this group. + + Args: + to_project_id (int): ID of the project to transfer + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabTransferProjectError: If the project could not be transfered + """ + path = '/groups/%d/projects/%d' % (self.id, to_project_id) + self.manager.gitlab.http_post(path, **kwargs) + + +class GroupManager(CRUDMixin, RESTManager): + _path = '/groups' + _obj_cls = Group + _create_attrs = ( + ('name', 'path'), + ('description', 'visibility', 'parent_id', 'lfs_enabled', + 'request_access_enabled') + ) + _update_attrs = ( + tuple(), + ('name', 'path', 'description', 'visibility', 'lfs_enabled', + 'request_access_enabled') + ) class Hook(ObjectDeleteMixin, RESTObject): @@ -420,9 +579,6 @@ class HookManager(NoUpdateMixin, RESTManager): class Issue(RESTObject): _url = '/issues' - _constructor_types = {'author': 'User', - 'assignee': 'User', - 'milestone': 'ProjectMilestone'} _short_print_attr = 'title' @@ -444,7 +600,6 @@ class LicenseManager(RetrieveMixin, RESTManager): class Snippet(SaveMixin, ObjectDeleteMixin, RESTObject): - _constructor_types = {'author': 'User'} _short_print_attr = 'title' @cli.register_custom_action('Snippet') @@ -510,7 +665,7 @@ class NamespaceManager(GetFromListMixin, RESTManager): class ProjectBoardList(SaveMixin, ObjectDeleteMixin, RESTObject): - _constructor_types = {'label': 'ProjectLabel'} + pass class ProjectBoardListManager(CRUDMixin, RESTManager): @@ -523,7 +678,6 @@ class ProjectBoardListManager(CRUDMixin, RESTManager): class ProjectBoard(RESTObject): - _constructor_types = {'labels': 'ProjectBoardList'} _managers = (('lists', 'ProjectBoardListManager'), ) @@ -534,7 +688,6 @@ class ProjectBoardManager(GetFromListMixin, RESTManager): class ProjectBranch(ObjectDeleteMixin, RESTObject): - _constructor_types = {'author': 'User', "committer": "User"} _id_attr = 'name' @cli.register_custom_action('ProjectBranch', tuple(), @@ -587,10 +740,6 @@ class ProjectBranchManager(NoUpdateMixin, RESTManager): class ProjectJob(RESTObject): - _constructor_types = {'user': 'User', - 'commit': 'ProjectCommit', - 'runner': 'Runner'} - @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabJobCancelError) def cancel(self, **kwargs): @@ -909,7 +1058,7 @@ class ProjectHookManager(CRUDMixin, RESTManager): class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject): - _constructor_types = {'author': 'User'} + pass class ProjectIssueNoteManager(CRUDMixin, RESTManager): @@ -922,8 +1071,6 @@ class ProjectIssueNoteManager(CRUDMixin, RESTManager): class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, ObjectDeleteMixin, RESTObject): - _constructor_types = {'author': 'User', 'assignee': 'User', 'milestone': - 'ProjectMilestone'} _short_print_attr = 'title' _id_attr = 'iid' _managers = (('notes', 'ProjectIssueNoteManager'), ) @@ -980,7 +1127,7 @@ class ProjectMemberManager(CRUDMixin, RESTManager): class ProjectNote(RESTObject): - _constructor_types = {'author': 'User'} + pass class ProjectNoteManager(RetrieveMixin, RESTManager): @@ -1001,8 +1148,6 @@ class ProjectNotificationSettingsManager(NotificationSettingsManager): class ProjectTag(ObjectDeleteMixin, RESTObject): - _constructor_types = {'release': 'ProjectTagRelease', - 'commit': 'ProjectCommit'} _id_attr = 'name' _short_print_attr = 'name' @@ -1022,7 +1167,8 @@ def set_release_description(self, description, **kwargs): GitlabCreateError: If the server fails to create the release GitlabUpdateError: If the server fails to update the release """ - path = '%s/%s/release' % (self.manager.path, self.get_id()) + id = self.get_id().replace('/', '%2F') + path = '%s/%s/release' % (self.manager.path, id) data = {'description': description} if self.release is None: try: @@ -1059,7 +1205,7 @@ class ProjectMergeRequestDiffManager(RetrieveMixin, RESTManager): class ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject): - _constructor_types = {'author': 'User'} + pass class ProjectMergeRequestNoteManager(CRUDMixin, RESTManager): @@ -1072,7 +1218,6 @@ class ProjectMergeRequestNoteManager(CRUDMixin, RESTManager): class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, ObjectDeleteMixin, RESTObject): - _constructor_types = {'author': 'User', 'assignee': 'User'} _id_attr = 'iid' _managers = ( @@ -1242,8 +1387,8 @@ def issues(self, **kwargs): path = '%s/%s/issues' % (self.manager.path, self.get_id()) data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) - manager = ProjectCommitManager(self.manager.gitlab, - parent=self.manager._parent) + manager = ProjectIssueManager(self.manager.gitlab, + parent=self.manager._parent) # FIXME(gpocentek): the computed manager path is not correct return RESTObjectList(manager, ProjectIssue, data_list) @@ -1265,8 +1410,8 @@ def merge_requests(self, **kwargs): path = '%s/%s/merge_requests' % (self.manager.path, self.get_id()) data_list = self.manager.gitlab.http_list(path, as_list=False, **kwargs) - manager = ProjectCommitManager(self.manager.gitlab, - parent=self.manager._parent) + manager = ProjectMergeRequestManager(self.manager.gitlab, + parent=self.manager._parent) # FIXME(gpocentek): the computed manager path is not correct return RESTObjectList(manager, ProjectMergeRequest, data_list) @@ -1279,7 +1424,7 @@ class ProjectMilestoneManager(CRUDMixin, RESTManager): 'state_event')) _update_attrs = (tuple(), ('title', 'description', 'due_date', 'start_date', 'state_event')) - _list_filters = ('iids', 'state') + _list_filters = ('iids', 'state', 'search') class ProjectLabel(SubscribableMixin, SaveMixin, ObjectDeleteMixin, @@ -1431,11 +1576,35 @@ def create(self, data, **kwargs): """ self._check_missing_create_attrs(data) - file_path = data.pop('file_path') + file_path = data.pop('file_path').replace('/', '%2F') path = '%s/%s' % (self.path, file_path) server_data = self.gitlab.http_post(path, post_data=data, **kwargs) return self._obj_cls(self, server_data) + @exc.on_http_error(exc.GitlabUpdateError) + def update(self, file_path, new_data={}, **kwargs): + """Update an object on the server. + + Args: + id: ID of the object to update (can be None if not required) + new_data: the update data for the object + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Returns: + dict: The new object data (*not* a RESTObject) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server cannot perform the request + """ + + data = new_data.copy() + file_path = file_path.replace('/', '%2F') + data['file_path'] = file_path + path = '%s/%s' % (self.path, file_path) + self._check_missing_update_attrs(data) + return self.gitlab.http_put(path, post_data=data, **kwargs) + @cli.register_custom_action('ProjectFileManager', ('file_path', 'branch', 'commit_message')) @exc.on_http_error(exc.GitlabDeleteError) @@ -1546,21 +1715,21 @@ def create(self, data, **kwargs): return CreateMixin.create(self, data, path=path, **kwargs) -class ProjectSnippetNote(RESTObject): - _constructor_types = {'author': 'User'} +class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject): + pass -class ProjectSnippetNoteManager(RetrieveMixin, CreateMixin, RESTManager): +class ProjectSnippetNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes' _obj_cls = ProjectSnippetNote _from_parent_attrs = {'project_id': 'project_id', 'snippet_id': 'id'} _create_attrs = (('body', ), tuple()) + _update_attrs = (('body', ), tuple()) class ProjectSnippet(SaveMixin, ObjectDeleteMixin, RESTObject): _url = '/projects/%(project_id)s/snippets' - _constructor_types = {'author': 'User'} _short_print_attr = 'title' _managers = (('notes', 'ProjectSnippetNoteManager'), ) @@ -1617,6 +1786,17 @@ class ProjectTriggerManager(CRUDMixin, RESTManager): _update_attrs = (('description', ), tuple()) +class ProjectUser(RESTObject): + pass + + +class ProjectUserManager(ListMixin, RESTManager): + _path = '/projects/%(project_id)s/users' + _obj_cls = ProjectUser + _from_parent_attrs = {'project_id': 'id'} + _list_filters = ('search',) + + class ProjectVariable(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'key' @@ -1767,8 +1947,21 @@ class ProjectRunnerManager(NoUpdateMixin, RESTManager): _create_attrs = (('runner_id', ), tuple()) +class ProjectWiki(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = 'slug' + _short_print_attr = 'slug' + + +class ProjectWikiManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/wikis' + _obj_cls = ProjectWiki + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('title', 'content'), ('format', )) + _update_attrs = (tuple(), ('title', 'content', 'format')) + _list_filters = ('with_content', ) + + class Project(SaveMixin, ObjectDeleteMixin, RESTObject): - _constructor_types = {'owner': 'User', 'namespace': 'Group'} _short_print_attr = 'path' _managers = ( ('accessrequests', 'ProjectAccessRequestManager'), @@ -1796,8 +1989,10 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('services', 'ProjectServiceManager'), ('snippets', 'ProjectSnippetManager'), ('tags', 'ProjectTagManager'), + ('users', 'ProjectUserManager'), ('triggers', 'ProjectTriggerManager'), ('variables', 'ProjectVariableManager'), + ('wikis', 'ProjectWikiManager'), ) @cli.register_custom_action('Project', tuple(), ('path', 'ref')) @@ -1829,7 +2024,7 @@ def repository_tree(self, path='', ref='', **kwargs): @cli.register_custom_action('Project', ('sha', )) @exc.on_http_error(exc.GitlabGetError) def repository_blob(self, sha, **kwargs): - """Return a blob by blob SHA. + """Return a file by blob SHA. Args: sha(str): ID of the blob @@ -1840,7 +2035,7 @@ def repository_blob(self, sha, **kwargs): GitlabGetError: If the server failed to perform the request Returns: - str: The blob metadata + dict: The blob content and metadata """ path = '/projects/%s/repository/blobs/%s' % (self.get_id(), sha) @@ -2080,9 +2275,7 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): GitlabCreateError: If the server failed to perform the request """ path = '/projects/%s/trigger/pipeline' % self.get_id() - form = {r'variables[%s]' % k: v for k, v in six.iteritems(variables)} - post_data = {'ref': ref, 'token': token} - post_data.update(form) + post_data = {'ref': ref, 'token': token, 'variables': variables} self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) # see #56 - add file attachment features @@ -2139,6 +2332,34 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): } +class ProjectManager(CRUDMixin, RESTManager): + _path = '/projects' + _obj_cls = Project + _create_attrs = ( + ('name', ), + ('path', 'namespace_id', 'description', 'issues_enabled', + 'merge_requests_enabled', 'jobs_enabled', 'wiki_enabled', + 'snippets_enabled', 'container_registry_enabled', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_jobs', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', + 'request_access_enabled', 'printing_merge_request_link_enabled') + ) + _update_attrs = ( + tuple(), + ('name', 'path', 'default_branch', 'description', 'issues_enabled', + 'merge_requests_enabled', 'jobs_enabled', 'wiki_enabled', + 'snippets_enabled', 'container_registry_enabled', + 'shared_runners_enabled', 'visibility', 'import_url', 'public_jobs', + 'only_allow_merge_if_build_succeeds', + 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', + 'request_access_enabled', 'printing_merge_request_link_enabled') + ) + _list_filters = ('search', 'owned', 'starred', 'archived', 'visibility', + 'order_by', 'sort', 'simple', 'membership', 'statistics', + 'with_issues_enabled', 'with_merge_requests_enabled') + + class Runner(SaveMixin, ObjectDeleteMixin, RESTObject): pass @@ -2221,98 +2442,3 @@ def mark_all_as_done(self, **kwargs): return int(result) except ValueError: return 0 - - -class ProjectManager(CRUDMixin, RESTManager): - _path = '/projects' - _obj_cls = Project - _create_attrs = ( - ('name', ), - ('path', 'namespace_id', 'description', 'issues_enabled', - 'merge_requests_enabled', 'jobs_enabled', 'wiki_enabled', - 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', 'import_url', 'public_jobs', - 'only_allow_merge_if_build_succeeds', - 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', - 'request_access_enabled') - ) - _update_attrs = ( - tuple(), - ('name', 'path', 'default_branch', 'description', 'issues_enabled', - 'merge_requests_enabled', 'jobs_enabled', 'wiki_enabled', - 'snippets_enabled', 'container_registry_enabled', - 'shared_runners_enabled', 'visibility', 'import_url', 'public_jobs', - 'only_allow_merge_if_build_succeeds', - 'only_allow_merge_if_all_discussions_are_resolved', 'lfs_enabled', - 'request_access_enabled') - ) - _list_filters = ('search', 'owned', 'starred', 'archived', 'visibility', - 'order_by', 'sort', 'simple', 'membership', 'statistics', - 'with_issues_enabled', 'with_merge_requests_enabled') - - -class GroupProject(Project): - pass - - -class GroupProjectManager(GetFromListMixin, RESTManager): - _path = '/groups/%(group_id)s/projects' - _obj_cls = GroupProject - _from_parent_attrs = {'group_id': 'id'} - _list_filters = ('archived', 'visibility', 'order_by', 'sort', 'search', - 'ci_enabled_first') - - -class GroupVariable(SaveMixin, ObjectDeleteMixin, RESTObject): - _id_attr = 'key' - - -class GroupVariableManager(CRUDMixin, RESTManager): - _path = '/groups/%(group_id)s/variables' - _obj_cls = GroupVariable - _from_parent_attrs = {'group_id': 'id'} - _create_attrs = (('key', 'value'), ('protected',)) - _update_attrs = (('key', 'value'), ('protected',)) - - -class Group(SaveMixin, ObjectDeleteMixin, RESTObject): - _short_print_attr = 'name' - _managers = ( - ('accessrequests', 'GroupAccessRequestManager'), - ('members', 'GroupMemberManager'), - ('notificationsettings', 'GroupNotificationSettingsManager'), - ('projects', 'GroupProjectManager'), - ('issues', 'GroupIssueManager'), - ('variables', 'GroupVariableManager'), - ) - - @cli.register_custom_action('Group', ('to_project_id', )) - @exc.on_http_error(exc.GitlabTransferProjectError) - def transfer_project(self, to_project_id, **kwargs): - """Transfer a project to this group. - - Args: - to_project_id (int): ID of the project to transfer - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabTransferProjectError: If the project could not be transfered - """ - path = '/groups/%d/projects/%d' % (self.id, to_project_id) - self.manager.gitlab.http_post(path, **kwargs) - - -class GroupManager(CRUDMixin, RESTManager): - _path = '/groups' - _obj_cls = Group - _create_attrs = ( - ('name', 'path'), - ('description', 'visibility', 'parent_id', 'lfs_enabled', - 'request_access_enabled') - ) - _update_attrs = ( - tuple(), - ('name', 'path', 'description', 'visibility', 'lfs_enabled', - 'request_access_enabled') - ) diff --git a/tools/cli_test_v4.sh b/tools/cli_test_v4.sh index 813d85b06..01f84e830 100644 --- a/tools/cli_test_v4.sh +++ b/tools/cli_test_v4.sh @@ -95,9 +95,18 @@ testcase "branch deletion" ' ' testcase "project upload" ' - GITLAB project upload --id "$PROJECT_ID" --filename '$(basename $0)' --filepath '$0' + GITLAB project upload --id "$PROJECT_ID" \ + --filename '$(basename $0)' --filepath '$0' >/dev/null 2>&1 ' testcase "project deletion" ' GITLAB project delete --id "$PROJECT_ID" ' + +testcase "application settings get" ' + GITLAB application-settings get >/dev/null 2>&1 +' + +testcase "application settings update" ' + GITLAB application-settings update --signup-enabled false +' diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 386b59b7d..0b1793a78 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -20,6 +20,37 @@ "rke9IepE7SPBT41C+YtUX4dfDZDmczM1cE0YL/krdUCfuZHMa4ZS2YyNd6slufc" "vn bar@foo") +GPG_KEY = '''-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFn5mzYBCADH6SDVPAp1zh/hxmTi0QplkOfExBACpuY6OhzNdIg+8/528b3g +Y5YFR6T/HLv/PmeHskUj21end1C0PNG2T9dTx+2Vlh9ISsSG1kyF9T5fvMR3bE0x +Dl6S489CXZrjPTS9SHk1kF+7dwjUxLJyxF9hPiSihFefDFu3NeOtG/u8vbC1mewQ +ZyAYue+mqtqcCIFFoBz7wHKMWjIVSJSyTkXExu4OzpVvy3l2EikbvavI3qNz84b+ +Mgkv/kiBlNoCy3CVuPk99RYKZ3lX1vVtqQ0OgNGQvb4DjcpyjmbKyibuZwhDjIOh +au6d1OyEbayTntd+dQ4j9EMSnEvm/0MJ4eXPABEBAAG0G0dpdGxhYlRlc3QxIDxm +YWtlQGZha2UudGxkPokBNwQTAQgAIQUCWfmbNgIbAwULCQgHAgYVCAkKCwIEFgID +AQIeAQIXgAAKCRBgxELHf8f3hF3yB/wNJlWPKY65UsB4Lo0hs1OxdxCDqXogSi0u +6crDEIiyOte62pNZKzWy8TJcGZvznRTZ7t8hXgKFLz3PRMcl+vAiRC6quIDUj+2V +eYfwaItd1lUfzvdCaC7Venf4TQ74f5vvNg/zoGwE6eRoSbjlLv9nqsxeA0rUBUQL +LYikWhVMP3TrlfgfduYvh6mfgh57BDLJ9kJVpyfxxx9YLKZbaas9sPa6LgBtR555 +JziUxHmbEv8XCsUU8uoFeP1pImbNBplqE3wzJwzOMSmmch7iZzrAwfN7N2j3Wj0H +B5kQddJ9dmB4BbU0IXGhWczvdpxboI2wdY8a1JypxOdePoph/43iuQENBFn5mzYB +CADnTPY0Zf3d9zLjBNgIb3yDl94uOcKCq0twNmyjMhHzGqw+UMe9BScy34GL94Al +xFRQoaL+7P8hGsnsNku29A/VDZivcI+uxTx4WQ7OLcn7V0bnHV4d76iky2ufbUt/ +GofthjDs1SonePO2N09sS4V4uK0d5N4BfCzzXgvg8etCLxNmC9BGt7AaKUUzKBO4 +2QvNNaC2C/8XEnOgNWYvR36ylAXAmo0sGFXUsBCTiq1fugS9pwtaS2JmaVpZZ3YT +pMZlS0+SjC5BZYFqSmKCsA58oBRzCxQz57nR4h5VEflgD+Hy0HdW0UHETwz83E6/ +U0LL6YyvhwFr6KPq5GxinSvfABEBAAGJAR8EGAEIAAkFAln5mzYCGwwACgkQYMRC +x3/H94SJgwgAlKQb10/xcL/epdDkR7vbiei7huGLBpRDb/L5fM8B5W77Qi8Xmuqj +cCu1j99ZCA5hs/vwVn8j8iLSBGMC5gxcuaar/wtmiaEvT9fO/h6q4opG7NcuiJ8H +wRj8ccJmRssNqDD913PLz7T40Ts62blhrEAlJozGVG/q7T3RAZcskOUHKeHfc2RI +YzGsC/I9d7k6uxAv1L9Nm5F2HaAQDzhkdd16nKkGaPGR35cT1JLInkfl5cdm7ldN +nxs4TLO3kZjUTgWKdhpgRNF5hwaz51ZjpebaRf/ZqRuNyX4lIRolDxzOn/+O1o8L +qG2ZdhHHmSK2LaQLFiSprUkikStNU9BqSQ== +=5OGa +-----END PGP PUBLIC KEY BLOCK-----''' + + # login/password authentication gl = gitlab.Gitlab('http://localhost:8080', email=LOGIN, password=PASSWORD) gl.auth() @@ -80,6 +111,14 @@ foobar_user.bio = 'This is the user bio' foobar_user.save() +# GPG keys +gkey = new_user.gpgkeys.create({'key': GPG_KEY}) +assert(len(new_user.gpgkeys.list()) == 1) +# Seems broken on the gitlab side +# gkey = new_user.gpgkeys.get(gkey.id) +gkey.delete() +assert(len(new_user.gpgkeys.list()) == 0) + # SSH keys key = new_user.keys.create({'title': 'testkey', 'key': SSH_KEY}) assert(len(new_user.keys.list()) == 1) @@ -102,6 +141,14 @@ mail.delete() assert(len(gl.user.emails.list()) == 0) +# current user GPG keys +gkey = gl.user.gpgkeys.create({'key': GPG_KEY}) +assert(len(gl.user.gpgkeys.list()) == 1) +# Seems broken on the gitlab side +gkey = gl.user.gpgkeys.get(gkey.id) +gkey.delete() +assert(len(gl.user.gpgkeys.list()) == 0) + # current user key key = gl.user.keys.create({'title': 'testkey', 'key': SSH_KEY}) assert(len(gl.user.keys.list()) == 1) @@ -170,6 +217,18 @@ settings = group2.notificationsettings.get() assert(settings.level == 'disabled') +# group milestones +gm1 = group1.milestones.create({'title': 'groupmilestone1'}) +assert(len(group1.milestones.list()) == 1) +gm1.due_date = '2020-01-01T00:00:00Z' +gm1.save() +gm1.state_event = 'close' +gm1.save() +gm1 = group1.milestones.get(gm1.id) +assert(gm1.state == 'closed') +assert(len(gm1.issues()) == 0) +assert(len(gm1.merge_requests()) == 0) + # group variables group1.variables.create({'key': 'foo', 'value': 'bar'}) g_v = group1.variables.get('foo') @@ -330,8 +389,10 @@ m1.save() m1.state_event = 'close' m1.save() -m1 = admin_project.milestones.get(1) +m1 = admin_project.milestones.get(m1.id) assert(m1.state == 'closed') +assert(len(m1.issues()) == 0) +assert(len(m1.merge_requests()) == 0) # issues issue1 = admin_project.issues.create({'title': 'my issue 1', @@ -435,6 +496,18 @@ #lists = board.lists.list() #assert(len(lists) == begin_size - 1) +# project wiki +wiki_content = 'Wiki page content' +wp = admin_project.wikis.create({'title': 'wikipage', 'content': wiki_content}) +assert(len(admin_project.wikis.list()) == 1) +wp = admin_project.wikis.get(wp.slug) +assert(wp.content == wiki_content) +# update and delete seem broken +# wp.content = 'new content' +# wp.save() +# wp.delete() +# assert(len(admin_project.wikis.list()) == 0) + # namespaces ns = gl.namespaces.list(all=True) assert(len(ns) != 0)