diff options
42 files changed, 1080 insertions, 449 deletions
@@ -17,7 +17,10 @@ Andrew Austin <aaustin@terremark.com> Armin Weihbold <armin.weihbold@gmail.com> Aron Pammer <info@aronpammer.me> Asher256 <Asher256@users.noreply.github.com> +Bancarel Valentin <bancarel.valentin@gmail.com> +Ben Brown <ben.brown@codethink.co.uk> Carlo Mion <mion00@users.noreply.github.com> +Carlos Soriano <csoriano@gnome.org> Christian <cgumpert@users.noreply.github.com> Christian Wenk <christian.wenk@omicronenergy.com> Colin D Bennett <colin.bennett@harman.com> @@ -28,6 +31,7 @@ derek-austin <derek.austin35@mailinator.com> Diego Giovane Pasqualin <dpasqualin@c3sl.ufpr.br> Dmytro Litvinov <litvinov.do.it@gmail.com> Eli Sarver <eli.sarver@gmail.com> +Eric L Frederich <eric.frederich@siemens.com> Erik Weatherwax <erik.weatherwax@xls.xerox.com> fgouteroux <francois.gouteroux@d2-si.eu> Greg Allen <GregoryEAllen@users.noreply.github.com> @@ -61,12 +65,14 @@ Mikhail Lopotkov <ms.lopotkov@tensor.ru> Missionrulz <missionrulz@gmail.com> Mond WAN <mondwan@users.noreply.github.com> Nathan Giesbrecht <NathanGiesbrecht@users.noreply.github.com> +Nathan Schmidt <nathan@cascade-softworks.com> pa4373 <pa4373@gmail.com> Patrick Miller <patrick@velocitywebworks.com> Pavel Savchenko <asfaltboy@gmail.com> Peng Xiao <xiaoquwl@gmail.com> Pete Browne <pete.browne@localmed.com> Peter Mosmans <support@go-forward.net> +P. F. Chimento <philip.chimento@gmail.com> Philipp Busch <philipp.busch@momox.biz> Rafael Eyng <rafaeleyng@gmail.com> Richard Hansen <rhansen@rhansen.org> @@ -76,6 +82,7 @@ savenger <github@smahmood.de> Stefan K. Dunkler <stefan.dun@gmail.com> Stefan Klug <klug.stefan@gmx.de> Stefano Mandruzzato <stefano.mandruzzato@gmail.com> +THEBAULT Julien <julien@thebault.co> Tim Neumann <mail@timnn.me> Will Starms <vilhelmen@gmail.com> Yosi Zelensky <yosyos04@gmail.com> diff --git a/ChangeLog.rst b/ChangeLog.rst index fe6b201..3049b9a 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,6 +1,46 @@ ChangeLog ========= +Version 1.2.0_ - 2018-01-01 +--------------------------- + +* Add mattermost service support +* Add users custom attributes support +* [doc] Fix project.triggers.create example with v4 API +* Oauth token support +* Remove deprecated objects/methods +* Rework authentication args handling +* Add support for oauth and anonymous auth in config/CLI +* Add support for impersonation tokens API +* Add support for user activities +* Update user docs with gitlab URLs +* [docs] Bad arguments in projetcs file documentation +* Add support for user_agent_detail (issues) +* Add a SetMixin +* Add support for project housekeeping +* Expected HTTP response for subscribe is 201 +* Update pagination docs for ProjectCommit +* Add doc to get issue from iid +* Make todo() raise GitlabTodoError on error +* Add support for award emojis +* Update project services docs for v4 +* Avoid sending empty update data to issue.save +* [docstrings] Explicitly document pagination arguments +* [docs] Add a note about password auth being removed from GitLab +* Submanagers: allow having undefined parameters +* ProjectFile.create(): don't modify the input data +* Update testing tools for /session removal +* Update groups tests +* Allow per_page to be used with generators +* Add groups listing attributes +* Add support for subgroups listing +* Add supported python versions in setup.py +* Add support for pagesdomains +* Add support for features flags +* Add support for project and group custom variables +* Add support for user/group/project filter by custom attribute +* Respect content of REQUESTS_CA_BUNDLE and *_proxy envvars + Version 1.1.0_ - 2017-11-03 --------------------------- @@ -495,6 +535,7 @@ Version 0.1 - 2013-07-08 * Initial release +.. _1.2.0: https://github.com/python-gitlab/python-gitlab/compare/1.1.0...1.2.0 .. _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 @@ -7,6 +7,9 @@ .. image:: https://readthedocs.org/projects/python-gitlab/badge/?version=latest :target: https://python-gitlab.readthedocs.org/en/latest/?badge=latest +.. image:: https://img.shields.io/pypi/pyversions/python-gitlab.svg + :target: https://pypi.python.org/pypi/python-gitlab + Python GitLab ============= diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index a9008f7..da2545f 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -4,9 +4,17 @@ Release notes This page describes important changes between python-gitlab releases. +Changes from 1.2 to 1.3 +======================= + +* ``gitlab.Gitlab`` objects can be used as context managers in a ``with`` + block. + Changes from 1.1 to 1.2 ======================= +* python-gitlab now respects the ``*_proxy``, ``REQUESTS_CA_BUNDLE`` and + ``CURL_CA_BUNDLE`` environment variables (#352) * The following deprecated methods and objects have been removed: * gitlab.v3.object ``Key`` and ``KeyManager`` objects: use ``DeployKey`` and diff --git a/docs/api-objects.rst b/docs/api-objects.rst index e549924..f2e72e2 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -6,6 +6,7 @@ API examples :maxdepth: 1 gl_objects/access_requests + gl_objects/emojis gl_objects/branches gl_objects/protected_branches gl_objects/messages @@ -14,6 +15,8 @@ API examples gl_objects/deploy_keys gl_objects/deployments gl_objects/environments + gl_objects/events + gl_objects/features gl_objects/groups gl_objects/issues gl_objects/labels @@ -21,6 +24,7 @@ API examples gl_objects/mrs gl_objects/namespaces gl_objects/milestones + gl_objects/pagesdomains gl_objects/projects gl_objects/runners gl_objects/settings diff --git a/docs/api-usage.rst b/docs/api-usage.rst index f60c0dc..190482f 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -7,7 +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. +v4 is the default API used by python-gitlab since version 1.3.0. ``gitlab.Gitlab`` class @@ -19,13 +19,13 @@ To connect to a GitLab server, create a ``gitlab.Gitlab`` object: import gitlab - # private token authentication + # private token or personal token authentication gl = gitlab.Gitlab('http://10.0.0.1', private_token='JVNSESs8EwWRx5yDxM5q') # oauth token authentication gl = gitlab.Gitlab('http://10.0.0.1', oauth_token='my_long_token_here') - # username/password authentication + # username/password authentication (for GitLab << 10.2) gl = gitlab.Gitlab('http://10.0.0.1', email='jdoe', password='s3cr3t') # anonymous gitlab instance, read-only for public resources @@ -44,25 +44,38 @@ You can also use configuration files to create ``gitlab.Gitlab`` objects: See the :ref:`cli_configuration` section for more information about configuration files. +Note on password authentication +------------------------------- + +The ``/session`` API endpoint used for username/password authentication has +been removed from GitLab in version 10.2, and is not available on gitlab.com +anymore. Personal token authentication is the prefered authentication method. + +If you need username/password authentication, you can use cookie-based +authentication. You can use the web UI form to authenticate, retrieve cookies, +and then use a custom ``requests.Session`` object to connect to the GitLab API. +The following code snippet demonstrates how to automate this: +https://gist.github.com/gpocentek/bd4c3fbf8a6ce226ebddc4aad6b46c0a. + +See `issue 380 <https://github.com/python-gitlab/python-gitlab/issues/380>`_ +for a detailed discussion. API version =========== -``python-gitlab`` uses the v3 GitLab API by default. Use the ``api_version`` -parameter to switch to v4: +``python-gitlab`` uses the v4 GitLab API by default. Use the ``api_version`` +parameter to switch to v3: .. code-block:: python import gitlab - gl = gitlab.Gitlab('http://10.0.0.1', 'JVNSESs8EwWRx5yDxM5q', api_version=4) + gl = gitlab.Gitlab('http://10.0.0.1', 'JVNSESs8EwWRx5yDxM5q', api_version=3) .. warning:: The python-gitlab API is not the same for v3 and v4. Make sure to read - :ref:`switching_to_v4` before upgrading. - - v4 will become the default in python-gitlab. + :ref:`switching_to_v4` if you are upgrading from v3. Managers ======== @@ -203,7 +216,7 @@ listing methods support the ``page`` and ``per_page`` parameters: .. note:: - The first page is page 1, not page 0. + The first page is page 1, not page 0, except for project commits in v3 API. By default GitLab does not return the complete list of items. Use the ``all`` parameter to get all the items when using listing methods: @@ -259,6 +272,23 @@ HTTP requests to the Gitlab servers. You can provide your own ``Session`` object with custom configuration when you create a ``Gitlab`` object. +Context manager +--------------- + +You can use ``Gitlab`` objects as context managers. This makes sure that the +``requests.Session`` object associated with a ``Gitlab`` instance is always +properly closed when you exit a ``with`` block: + +.. code-block:: python + + with gitlab.Gitlab(host, token) as gl: + gl.projects.list() + +.. warning:: + + The context manager will also close the custom ``Session`` object you might + have used to build a ``Gitlab`` instance. + Proxy configuration ------------------- diff --git a/docs/cli.rst b/docs/cli.rst index f75a46a..591761c 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -37,12 +37,11 @@ example: default = somewhere ssl_verify = true timeout = 5 - api_version = 3 [somewhere] url = https://some.whe.re private_token = vTbFeqJYCY3sibBP7BZM - api_version = 4 + api_version = 3 [elsewhere] url = http://else.whe.re:8080 @@ -69,6 +68,9 @@ parameters. You can override the values in each GitLab server section. * - ``timeout`` - Integer - Number of seconds to wait for an answer before failing. + * - ``api_version`` + - ``3`` ou ``4`` + - The API version to use to make queries. Requires python-gitlab >= 1.3.0. You must define the ``url`` in each GitLab server section. @@ -90,8 +92,8 @@ limited permissions. - An Oauth token for authentication. The Gitlab server must be configured to support this authentication method. * - ``api_version`` - - GitLab API version to use (``3`` or ``4``). Defaults to ``3`` for now, - but will switch to ``4`` eventually. + - GitLab API version to use (``3`` or ``4``). Defaults to ``4`` since + version 1.3.0. * - ``http_username`` - Username for optional HTTP authentication * - ``http_password`` diff --git a/docs/gl_objects/builds.py b/docs/gl_objects/builds.py index 803edc6..a5d2005 100644 --- a/docs/gl_objects/builds.py +++ b/docs/gl_objects/builds.py @@ -4,8 +4,8 @@ g_variables = group.variables.list() # end var list # var get -p_var = project.variables.get(var_key) -g_var = group.variables.get(var_key) +p_var = project.variables.get('key_name') +g_var = group.variables.get('key_name') # end var get # var create @@ -19,8 +19,8 @@ var.save() # end var update # var delete -project.variables.delete(var_key) -group.variables.delete(var_key) +project.variables.delete('key_name') +group.variables.delete('key_name') # or var.delete() # end var delete @@ -55,10 +55,18 @@ commit = gl.project_commits.get(commit_sha, project_id=1) builds = commit.builds() # end commit list -# get +# pipeline list get +# v4 only +project = gl.projects.get(project_id) +pipeline = project.pipelines.get(pipeline_id) +jobs = pipeline.jobs.list() # gets all jobs in pipeline +job = pipeline.jobs.get(job_id) # gets one job from pipeline +# end pipeline list get + +# get job project.builds.get(build_id) # v3 project.jobs.get(job_id) # v4 -# end get +# end get job # artifacts build_or_job.artifacts() diff --git a/docs/gl_objects/builds.rst b/docs/gl_objects/builds.rst index 1c95eb1..b0f3e22 100644 --- a/docs/gl_objects/builds.rst +++ b/docs/gl_objects/builds.rst @@ -122,8 +122,9 @@ Remove a variable: Builds/Jobs =========== -Builds/Jobs are associated to projects and commits. They provide information on -the builds/jobs that have been run, and methods to manipulate them. +Builds/Jobs are associated to projects, pipelines and commits. They provide +information on the builds/jobs that have been run, and methods to manipulate +them. Reference --------- @@ -169,11 +170,20 @@ To list builds for a specific commit, create a :start-after: # commit list :end-before: # end commit list +To list builds for a specific pipeline or get a single job within a specific +pipeline, create a +:class:`~gitlab.v4.objects.ProjectPipeline` object and use its +:attr:`~gitlab.v4.objects.ProjectPipeline.jobs` method (v4 only): + +.. literalinclude:: builds.py + :start-after: # pipeline list get + :end-before: # end pipeline list get + Get a job: .. literalinclude:: builds.py - :start-after: # get - :end-before: # end get + :start-after: # get job + :end-before: # end get job Get a job artifact: diff --git a/docs/gl_objects/commits.rst b/docs/gl_objects/commits.rst index 9267cae..8a32709 100644 --- a/docs/gl_objects/commits.rst +++ b/docs/gl_objects/commits.rst @@ -23,6 +23,11 @@ Reference * GitLab API: https://docs.gitlab.com/ce/api/commits.html +.. warning:: + + Pagination starts at page 0 in v3, but starts at page 1 in v4 (like all the + v4 endpoints). + Examples -------- diff --git a/docs/gl_objects/emojis.rst b/docs/gl_objects/emojis.rst new file mode 100644 index 0000000..179141f --- /dev/null +++ b/docs/gl_objects/emojis.rst @@ -0,0 +1,45 @@ +############ +Award Emojis +############ + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectIssueAwardEmoji` + + :class:`gitlab.v4.objects.ProjectIssueNoteAwardEmoji` + + :class:`gitlab.v4.objects.ProjectMergeRequestAwardEmoji` + + :class:`gitlab.v4.objects.ProjectMergeRequestNoteAwardEmoji` + + :class:`gitlab.v4.objects.ProjectSnippetAwardEmoji` + + :class:`gitlab.v4.objects.ProjectSnippetNoteAwardEmoji` + + :class:`gitlab.v4.objects.ProjectIssueAwardEmojiManager` + + :class:`gitlab.v4.objects.ProjectIssueNoteAwardEmojiManager` + + :class:`gitlab.v4.objects.ProjectMergeRequestAwardEmojiManager` + + :class:`gitlab.v4.objects.ProjectMergeRequestNoteAwardEmojiManager` + + :class:`gitlab.v4.objects.ProjectSnippetAwardEmojiManager` + + :class:`gitlab.v4.objects.ProjectSnippetNoteAwardEmojiManager` + + +* GitLab API: https://docs.gitlab.com/ce/api/award_emoji.html + +Examples +-------- + +List emojis for a resource:: + + emojis = obj.awardemojis.list() + +Get a single emoji:: + + emoji = obj.awardemojis.get(emoji_id) + +Add (create) an emoji:: + + emoji = obj.awardemojis.create({'name': 'tractor'}) + +Delete an emoji:: + + emoji.delete + # or + obj.awardemojis.delete(emoji_id) diff --git a/docs/gl_objects/events.rst b/docs/gl_objects/events.rst new file mode 100644 index 0000000..807dcad --- /dev/null +++ b/docs/gl_objects/events.rst @@ -0,0 +1,48 @@ +###### +Events +###### + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Event` + + :class:`gitlab.v4.objects.EventManager` + + :attr:`gitlab.Gitlab.events` + + :class:`gitlab.v4.objects.ProjectEvent` + + :class:`gitlab.v4.objects.ProjectEventManager` + + :attr:`gitlab.v4.objects.Project.events` + + :class:`gitlab.v4.objects.UserEvent` + + :class:`gitlab.v4.objects.UserEventManager` + + :attr:`gitlab.v4.objects.User.events` + +* v3 API (projects events only): + + + :class:`gitlab.v3.objects.ProjectEvent` + + :class:`gitlab.v3.objects.ProjectEventManager` + + :attr:`gitlab.v3.objects.Project.events` + + :attr:`gitlab.Gitlab.project_events` + +* GitLab API: https://docs.gitlab.com/ce/api/events.html + +Examples +-------- + +You can list events for an entire Gitlab instance (admin), users and projects. +You can filter you events you want to retrieve using the ``action`` and +``target_type`` attributes. The possibole values for these attributes are +available on `the gitlab documentation +<https://docs.gitlab.com/ce/api/events.html>`_. + +List all the events (paginated):: + + events = gl.events.list() + +List the issue events on a project:: + + events = project.events.list(target_type='issue') + +List the user events:: + + events = project.events.list() diff --git a/docs/gl_objects/features.rst b/docs/gl_objects/features.rst new file mode 100644 index 0000000..201d072 --- /dev/null +++ b/docs/gl_objects/features.rst @@ -0,0 +1,26 @@ +############## +Features flags +############## + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.Feature` + + :class:`gitlab.v4.objects.FeatureManager` + + :attr:`gitlab.Gitlab.features` + +* GitLab API: https://docs.gitlab.com/ce/api/features.html + +Examples +-------- + +List features:: + + features = gl.features.list() + +Create or set a feature:: + + feature = gl.features.set(feature_name, True) + feature = gl.features.set(feature_name, 30) diff --git a/docs/gl_objects/groups.py b/docs/gl_objects/groups.py deleted file mode 100644 index f1a2a8f..0000000 --- a/docs/gl_objects/groups.py +++ /dev/null @@ -1,50 +0,0 @@ -# list -groups = gl.groups.list() -# end list - -# get -group = gl.groups.get(group_id) -# end get - -# projects list -projects = group.projects.list() -# end projects list - -# create -group = gl.groups.create({'name': 'group1', 'path': 'group1'}) -# end create - -# update -group.description = 'My awesome group' -group.save() -# end update - -# delete -gl.group.delete(group_id) -# or -group.delete() -# end delete - -# member list -members = group.members.list() -# end member list - -# member get -members = group.members.get(member_id) -# end member get - -# member create -member = group.members.create({'user_id': user_id, - 'access_level': gitlab.GUEST_ACCESS}) -# end member create - -# member update -member.access_level = gitlab.DEVELOPER_ACCESS -member.save() -# end member update - -# member delete -group.members.delete(member_id) -# or -member.delete() -# end member delete diff --git a/docs/gl_objects/groups.rst b/docs/gl_objects/groups.rst index 5e413af..493f5d0 100644 --- a/docs/gl_objects/groups.rst +++ b/docs/gl_objects/groups.rst @@ -25,23 +25,17 @@ Reference Examples -------- -List the groups: +List the groups:: -.. literalinclude:: groups.py - :start-after: # list - :end-before: # end list + groups = gl.groups.list() -Get a group's detail: +Get a group's detail:: -.. literalinclude:: groups.py - :start-after: # get - :end-before: # end get + group = gl.groups.get(group_id) -List a group's projects: +List a group's projects:: -.. literalinclude:: groups.py - :start-after: # projects list - :end-before: # end projects list + projects = group.projects.list() You can filter and sort the result using the following parameters: @@ -54,23 +48,85 @@ You can filter and sort the result using the following parameters: * ``sort``: sort order: ``asc`` or ``desc`` * ``ci_enabled_first``: return CI enabled groups first -Create a group: +Create a group:: -.. literalinclude:: groups.py - :start-after: # create - :end-before: # end create + group = gl.groups.create({'name': 'group1', 'path': 'group1'}) -Update a group: +Update a group:: -.. literalinclude:: groups.py - :start-after: # update - :end-before: # end update + group.description = 'My awesome group' + group.save() -Remove a group: +Remove a group:: -.. literalinclude:: groups.py - :start-after: # delete - :end-before: # end delete + gl.group.delete(group_id) + # or + group.delete() + +Subgroups +========= + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.GroupSubgroup` + + :class:`gitlab.v4.objects.GroupSubgroupManager` + + :attr:`gitlab.v4.objects.Group.subgroups` + +Examples +-------- + +List the subgroups for a group:: + + subgroups = group.subgroups.list() + + # The GroupSubgroup objects don't expose the same API as the Group + # objects. If you need to manipulate a subgroup as a group, create a new + # Group object: + real_group = gl.groups.get(subgroup_id, lazy=True) + real_group.issues.list() + +Group custom attributes +======================= + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.GroupCustomAttribute` + + :class:`gitlab.v4.objects.GroupCustomAttributeManager` + + :attr:`gitlab.v4.objects.Group.customattributes` + +* GitLab API: https://docs.gitlab.com/ce/api/custom_attributes.html + +Examples +-------- + +List custom attributes for a group:: + + attrs = group.customattributes.list() + +Get a custom attribute for a group:: + + attr = group.customattributes.get(attr_key) + +Set (create or update) a custom attribute for a group:: + + attr = group.customattributes.set(attr_key, attr_value) + +Delete a custom attribute for a group:: + + attr.delete() + # or + group.customattributes.delete(attr_key) + +Search groups by custom attribute:: + + group.customattributes.set('role': 'admin') + gl.groups.list(custom_attributes={'role': 'admin'}) Group members ============= @@ -105,32 +161,26 @@ Reference Examples -------- -List group members: +List group members:: -.. literalinclude:: groups.py - :start-after: # member list - :end-before: # end member list + members = group.members.list() -Get a group member: +Get a group member:: -.. literalinclude:: groups.py - :start-after: # member get - :end-before: # end member get + members = group.members.get(member_id) -Add a member to the group: +Add a member to the group:: -.. literalinclude:: groups.py - :start-after: # member create - :end-before: # end member create + member = group.members.create({'user_id': user_id, + 'access_level': gitlab.GUEST_ACCESS}) -Update a member (change the access level): +Update a member (change the access level):: -.. literalinclude:: groups.py - :start-after: # member update - :end-before: # end member update + member.access_level = gitlab.DEVELOPER_ACCESS + member.save() -Remove a member from the group: +Remove a member from the group:: -.. literalinclude:: groups.py - :start-after: # member delete - :end-before: # end member delete + group.members.delete(member_id) + # or + member.delete() diff --git a/docs/gl_objects/issues.py b/docs/gl_objects/issues.py index 2e4645e..ef27e07 100644 --- a/docs/gl_objects/issues.py +++ b/docs/gl_objects/issues.py @@ -28,6 +28,10 @@ issues = project.issues.list(order_by='created_at', sort='desc') issue = project.issues.get(issue_id) # end project issues get +# project issues get from iid +issue = project.issues.list(iid=issue_iid)[0] +# end project issues get from iid + # project issues create issue = project.issues.create({'title': 'I have a bug', 'description': 'Something useful here.'}) diff --git a/docs/gl_objects/issues.rst b/docs/gl_objects/issues.rst index 4384ba9..136d8b8 100644 --- a/docs/gl_objects/issues.rst +++ b/docs/gl_objects/issues.rst @@ -104,6 +104,12 @@ Get a project issue: :start-after: # project issues get :end-before: # end project issues get +Get a project issue from its `iid` (v3 only. Issues are retrieved by iid in V4 by default): + +.. literalinclude:: issues.py + :start-after: # project issues get from iid + :end-before: # end project issues get from iid + Create a new issue: .. literalinclude:: issues.py diff --git a/docs/gl_objects/pagesdomains.rst b/docs/gl_objects/pagesdomains.rst new file mode 100644 index 0000000..d6b39c7 --- /dev/null +++ b/docs/gl_objects/pagesdomains.rst @@ -0,0 +1,65 @@ +############# +Pages domains +############# + +Admin +===== + +References +---------- + +* v4 API: + + + :class:`gitlab.v4.objects.PagesDomain` + + :class:`gitlab.v4.objects.PagesDomainManager` + + :attr:`gitlab.Gitlab.pagesdomains` + +* GitLab API: https://docs.gitlab.com/ce/api/pages_domains.html#list-all-pages-domains + +Examples +-------- + +List all the existing domains (admin only):: + + domains = gl.pagesdomains.list() + +Project pages domain +==================== + +References +---------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectPagesDomain` + + :class:`gitlab.v4.objects.ProjectPagesDomainManager` + + :attr:`gitlab.v4.objects.Project.pagesdomains` + +* GitLab API: https://docs.gitlab.com/ce/api/pages_domains.html#list-pages-domains + +Examples +-------- + +List domains for a project:: + + domains = project.pagesdomains.list() + +Get a single domain:: + + domain = project.pagesdomains.get('d1.example.com') + +Create a new domain:: + + domain = project.pagesdomains.create({'domain': 'd2.example.com}) + +Update an existing domain:: + + domain.certificate = open('d2.crt').read() + domain.key = open('d2.key').read() + domain.save() + +Delete an existing domain:: + + domain.delete + # or + project.pagesdomains.delete('d2.example.com') diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index 515397f..a633ee8 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -33,6 +33,7 @@ project = gl.projects.create({'name': 'project1'}) # user create alice = gl.users.list(username='alice')[0] user_project = alice.projects.create({'name': 'project'}) +user_projects = alice.projects.list() # end user create # update @@ -68,12 +69,6 @@ project.archive() project.unarchive() # end archive -# events list -gl.project_events.list(project_id=1) -# or -project.events.list() -# end events list - # members list members = project.members.list() # end members list @@ -229,7 +224,7 @@ tags = project.tags.list() # end tags list # tags get -tags = project.tags.list('1.0') +tag = project.tags.get('1.0') # end tags get # tags create @@ -305,8 +300,11 @@ note.delete() # end notes delete # service get +# For v3 service = project.services.get(service_name='asana', project_id=1) -# display it's status (enabled/disabled) +# For v4 +service = project.services.get('asana') +# display its status (enabled/disabled) print(service.active) # end service get diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index aaf0699..0c556f4 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -172,6 +172,46 @@ Get a list of users for the repository: :start-after: # users list :end-before: # end users list +Project custom attributes +========================= + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectCustomAttribute` + + :class:`gitlab.v4.objects.ProjectCustomAttributeManager` + + :attr:`gitlab.v4.objects.Project.customattributes` + +* GitLab API: https://docs.gitlab.com/ce/api/custom_attributes.html + +Examples +-------- + +List custom attributes for a project:: + + attrs = project.customattributes.list() + +Get a custom attribute for a project:: + + attr = project.customattributes.get(attr_key) + +Set (create or update) a custom attribute for a project:: + + attr = project.customattributes.set(attr_key, attr_value) + +Delete a custom attribute for a project:: + + attr.delete() + # or + project.customattributes.delete(attr_key) + +Search projects by custom attribute:: + + project.customattributes.set('type': 'internal') + gl.projects.list(custom_attributes={'type': 'internal'}) + Project files ============= @@ -444,36 +484,6 @@ Delete a note for a resource: :start-after: # notes delete :end-before: # end notes delete -Project events -============== - -Reference ---------- - -* v4 API: - - + :class:`gitlab.v4.objects.ProjectEvent` - + :class:`gitlab.v4.objects.ProjectEventManager` - + :attr:`gitlab.v4.objects.Project.events` - -* v3 API: - - + :class:`gitlab.v3.objects.ProjectEvent` - + :class:`gitlab.v3.objects.ProjectEventManager` - + :attr:`gitlab.v3.objects.Project.events` - + :attr:`gitlab.Gitlab.project_events` - -* GitLab API: https://docs.gitlab.com/ce/api/repository_files.html - -Examples --------- - -List the project events: - -.. literalinclude:: projects.py - :start-after: # events list - :end-before: # end events list - Project members =============== diff --git a/docs/gl_objects/settings.rst b/docs/gl_objects/settings.rst index 5f0e92f..cf3fd4d 100644 --- a/docs/gl_objects/settings.rst +++ b/docs/gl_objects/settings.rst @@ -17,7 +17,7 @@ Reference + :class:`gitlab.v3.objects.ApplicationSettingsManager` + :attr:`gitlab.Gitlab.settings` -* GitLab API: https://docs.gitlab.com/ce/api/commits.html +* GitLab API: https://docs.gitlab.com/ce/api/settings.html Examples -------- diff --git a/docs/gl_objects/users.py b/docs/gl_objects/users.py index e452217..842e35d 100644 --- a/docs/gl_objects/users.py +++ b/docs/gl_objects/users.py @@ -98,24 +98,6 @@ gl.auth() current_user = gl.user # end currentuser get -# ca list -attrs = user.customeattributes.list() -# end ca list - -# ca get -attr = user.customeattributes.get(attr_key) -# end ca get - -# ca set -attr = user.customeattributes.set(attr_key, attr_value) -# end ca set - -# ca delete -attr.delete() -# or -user.customeattributes.delete(attr_key) -# end ca delete - # it list i_t = user.impersonationtokens.list(state='active') i_t = user.impersonationtokens.list(state='inactive') diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst index e520c9b..63609db 100644 --- a/docs/gl_objects/users.rst +++ b/docs/gl_objects/users.rst @@ -89,29 +89,28 @@ References Examples -------- -List custom attributes for a user: +List custom attributes for a user:: -.. literalinclude:: users.py - :start-after: # ca list - :end-before: # end ca list + attrs = user.customattributes.list() -Get a custom attribute for a user: +Get a custom attribute for a user:: -.. literalinclude:: users.py - :start-after: # ca get - :end-before: # end ca get + attr = user.customattributes.get(attr_key) -Set (create or update) a custom attribute for a user: +Set (create or update) a custom attribute for a user:: -.. literalinclude:: users.py - :start-after: # ca set - :end-before: # end ca set + attr = user.customattributes.set(attr_key, attr_value) -Delete a custom attribute for a user: +Delete a custom attribute for a user:: -.. literalinclude:: users.py - :start-after: # ca list - :end-before: # end ca list + attr.delete() + # or + user.customattributes.delete(attr_key) + +Search users by custom attribute:: + + user.customattributes.set('role': 'QA') + gl.users.list(custom_attributes={'role': 'QA'}) User impersonation tokens ========================= diff --git a/docs/install.rst b/docs/install.rst index 1bc6d17..4998320 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -2,7 +2,7 @@ Installation ############ -``python-gitlab`` is compatible with python 2 and 3. +``python-gitlab`` is compatible with Python 2.7 and 3.4+. Use :command:`pip` to install the latest stable version of ``python-gitlab``: diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst index fff9573..ef21060 100644 --- a/docs/switching-to-v4.rst +++ b/docs/switching-to-v4.rst @@ -16,12 +16,12 @@ http://gitlab.com. Using the v4 API ================ -To use the new v4 API, explicitly define ``api_version` `in the ``Gitlab`` -constructor: +python-gitlab uses the v4 API by default since the 1.3.0 release. To use the +old v3 API, explicitly define ``api_version`` in the ``Gitlab`` constructor: .. code-block:: python - gl = gitlab.Gitlab(..., api_version=4) + gl = gitlab.Gitlab(..., api_version=3) If you use the configuration file, also explicitly define the version: @@ -30,13 +30,13 @@ If you use the configuration file, also explicitly define the version: [my_gitlab] ... - api_version = 4 + api_version = 3 Changes between v3 and v4 API ============================= -For a list of GtiLab (upstream) API changes, see +For a list of GitLab (upstream) API changes, see https://docs.gitlab.com/ce/api/v3_to_v4.html. The ``python-gitlab`` API reflects these changes. But also consider the @@ -95,7 +95,7 @@ following important changes in the python API: This will make only one API call, instead of two if ``lazy`` is not used. -* The :class:`~gitlab.Gitlab` folowwing methods should not be used anymore for +* The following :class:`~gitlab.Gitlab` methods should not be used anymore for v4: + ``list()`` diff --git a/gitlab/__init__.py b/gitlab/__init__.py index aac4837..c909f9f 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -34,7 +34,7 @@ from gitlab.exceptions import * # noqa from gitlab.v3.objects import * # noqa __title__ = 'python-gitlab' -__version__ = '1.1.0' +__version__ = '1.2.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' @@ -73,7 +73,7 @@ class Gitlab(object): def __init__(self, url, private_token=None, oauth_token=None, email=None, password=None, ssl_verify=True, http_username=None, - http_password=None, timeout=None, api_version='3', + http_password=None, timeout=None, api_version='4', session=None): self._api_version = str(api_version) @@ -125,6 +125,9 @@ class Gitlab(object): self.teams = objects.TeamManager(self) else: self.dockerfiles = objects.DockerfileManager(self) + self.events = objects.EventManager(self) + self.features = objects.FeatureManager(self) + self.pagesdomains = objects.PagesDomainManager(self) self.user_activities = objects.UserActivitiesManager(self) if self._api_version == '3': @@ -144,6 +147,12 @@ class Gitlab(object): manager = getattr(objects, cls_name)(self) setattr(self, var_name, manager) + def __enter__(self): + return self + + def __exit__(self, *args): + self.session.close() + def __getstate__(self): state = self.__dict__.copy() state.pop('_objects') @@ -640,8 +649,22 @@ class Gitlab(object): return parsed._replace(path=new_path).geturl() url = self._build_url(path) - params = query_data.copy() - params.update(kwargs) + + def copy_dict(dest, src): + for k, v in src.items(): + if isinstance(v, dict): + # Transform dict values in new attributes. For example: + # custom_attributes: {'foo', 'bar'} => + # custom_attributes['foo']: 'bar' + for dict_k, dict_v in v.items(): + dest['%s[%s]' % (k, dict_k)] = dict_v + else: + dest[k] = v + + params = {} + copy_dict(params, query_data) + copy_dict(params, kwargs) + opts = self._get_session_opts(content_type='application/json') # don't set the content-type header when uploading files @@ -661,8 +684,9 @@ class Gitlab(object): files=files, **opts) prepped = self.session.prepare_request(req) prepped.url = sanitized_url(prepped.url) - result = self.session.send(prepped, stream=streamed, verify=verify, - timeout=timeout) + settings = self.session.merge_environment_settings( + prepped.url, {}, streamed, verify, None) + result = self.session.send(prepped, timeout=timeout, **settings) if 200 <= result.status_code < 300: return result @@ -743,7 +767,7 @@ class Gitlab(object): if get_all is True: return list(GitlabList(self, url, query_data, **kwargs)) - if 'page' in kwargs or 'per_page' in kwargs or as_list is True: + if 'page' in kwargs or as_list is True: # pagination requested, we return a list return list(GitlabList(self, url, query_data, get_next=False, **kwargs)) diff --git a/gitlab/base.py b/gitlab/base.py index ec5f698..fd79c53 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -764,7 +764,7 @@ class RESTManager(object): if self._parent is None or not hasattr(self, '_from_parent_attrs'): return path - data = {self_attr: getattr(self._parent, parent_attr) + data = {self_attr: getattr(self._parent, parent_attr, None) for self_attr, parent_attr in self._from_parent_attrs.items()} self._parent_attrs = data return path % data diff --git a/gitlab/config.py b/gitlab/config.py index 9cf208c..0f4c424 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -128,7 +128,11 @@ class GitlabConfigParser(object): except Exception: pass - self.api_version = '3' + self.api_version = '4' + try: + self.api_version = self._config.get('global', 'api_version') + except Exception: + pass try: self.api_version = self._config.get(self.gitlab_id, 'api_version') except Exception: diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 9a423dd..5825d23 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -193,6 +193,10 @@ class GitlabHousekeepingError(GitlabOperationError): pass +class GitlabOwnershipError(GitlabOperationError): + pass + + def raise_error_from_response(response, error, expected_code=200): """Tries to parse gitlab error message from response and raises error. diff --git a/gitlab/mixins.py b/gitlab/mixins.py index c9243ed..cb35efc 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -242,7 +242,7 @@ class SetMixin(object): GitlabSetError: If an error occured Returns: - UserCustomAttribute: The created/updated user attribute + obj: The created/updated attribute """ path = '%s/%s' % (self.path, key.replace('/', '%2F')) data = {'value': value} @@ -303,6 +303,9 @@ class SaveMixin(object): GitlabUpdateError: If the server cannot perform the request """ updated_data = self._get_updated_data() + # Nothing to update. Server fails if sent an empty dict. + if not updated_data: + return # call the manager obj_id = self.get_id() diff --git a/gitlab/tests/test_base.py b/gitlab/tests/test_base.py index 31dd967..36cb63b 100644 --- a/gitlab/tests/test_base.py +++ b/gitlab/tests/test_base.py @@ -61,9 +61,6 @@ class TestRESTManager(unittest.TestCase): mgr = MGR(FakeGitlab(), parent=Parent()) self.assertEqual(mgr._computed_path, '/tests/42/cases') - self.assertRaises(AttributeError, MGR, FakeGitlab(), - parent=BrokenParent()) - def test_path_property(self): class MGR(base.RESTManager): _path = '/tests' diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index d33df99..1a1f3d8 100644 --- a/gitlab/tests/test_gitlab.py +++ b/gitlab/tests/test_gitlab.py @@ -53,7 +53,7 @@ class TestGitlabRawMethods(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", password="testpassword", - ssl_verify=True) + ssl_verify=True, api_version=3) @urlmatch(scheme="http", netloc="localhost", path="/api/v3/known_path", method="get") @@ -454,7 +454,7 @@ class TestGitlabMethods(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", password="testpassword", - ssl_verify=True) + ssl_verify=True, api_version=3) def test_list(self): @urlmatch(scheme="http", netloc="localhost", @@ -938,7 +938,7 @@ class TestGitlab(unittest.TestCase): def setUp(self): self.gl = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", password="testpassword", - ssl_verify=True) + ssl_verify=True, api_version=3) def test_pickability(self): original_gl_objects = self.gl._objects diff --git a/gitlab/tests/test_gitlabobject.py b/gitlab/tests/test_gitlabobject.py index f7fd187..844ba9e 100644 --- a/gitlab/tests/test_gitlabobject.py +++ b/gitlab/tests/test_gitlabobject.py @@ -34,7 +34,7 @@ from httmock import urlmatch # noqa from gitlab import * # noqa -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1", method="get") def resp_get_project(url, request): headers = {'content-type': 'application/json'} @@ -42,7 +42,7 @@ def resp_get_project(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", method="get") def resp_list_project(url, request): headers = {'content-type': 'application/json'} @@ -50,7 +50,7 @@ def resp_list_project(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/issues/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/issues/1", method="get") def resp_get_issue(url, request): headers = {'content-type': 'application/json'} @@ -58,7 +58,7 @@ def resp_get_issue(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/users/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/users/1", method="put") def resp_update_user(url, request): headers = {'content-type': 'application/json'} @@ -67,7 +67,7 @@ def resp_update_user(url, request): return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/projects", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects", method="post") def resp_create_project(url, request): headers = {'content-type': 'application/json'} @@ -75,7 +75,7 @@ def resp_create_project(url, request): return response(201, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/2/members", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/2/members", method="post") def resp_create_groupmember(url, request): headers = {'content-type': 'application/json'} @@ -84,14 +84,14 @@ def resp_create_groupmember(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/snippets/3", method="get") + path="/api/v4/projects/2/snippets/3", method="get") def resp_get_projectsnippet(url, request): headers = {'content-type': 'application/json'} content = '{"title": "test", "id": 3}'.encode("utf-8") return response(200, content, headers, None, 5, request) -@urlmatch(scheme="http", netloc="localhost", path="/api/v3/groups/1", +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1", method="delete") def resp_delete_group(url, request): headers = {'content-type': 'application/json'} @@ -100,7 +100,7 @@ def resp_delete_group(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/groups/2/projects/3", + path="/api/v4/groups/2/projects/3", method="post") def resp_transfer_project(url, request): headers = {'content-type': 'application/json'} @@ -109,7 +109,7 @@ def resp_transfer_project(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/groups/2/projects/3", + path="/api/v4/groups/2/projects/3", method="post") def resp_transfer_project_fail(url, request): headers = {'content-type': 'application/json'} @@ -118,7 +118,7 @@ def resp_transfer_project_fail(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/branches/branchname/protect", + path="/api/v4/projects/2/repository/branches/branchname/protect", method="put") def resp_protect_branch(url, request): headers = {'content-type': 'application/json'} @@ -127,7 +127,7 @@ def resp_protect_branch(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/branches/branchname/unprotect", + path="/api/v4/projects/2/repository/branches/branchname/unprotect", method="put") def resp_unprotect_branch(url, request): headers = {'content-type': 'application/json'} @@ -136,7 +136,7 @@ def resp_unprotect_branch(url, request): @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/branches/branchname/protect", + path="/api/v4/projects/2/repository/branches/branchname/protect", method="put") def resp_protect_branch_fail(url, request): headers = {'content-type': 'application/json'} @@ -157,7 +157,7 @@ class TestGitlabObject(unittest.TestCase): data = json.loads(json_str) self.assertIn("id", data) self.assertEqual(data["username"], "testname") - self.assertEqual(data["gitlab"]["url"], "http://localhost/api/v3") + self.assertEqual(data["gitlab"]["url"], "http://localhost/api/v4") def test_pickability(self): gl_object = CurrentUser(self.gl, data={"username": "testname"}) @@ -381,7 +381,7 @@ class TestProjectCommit(unittest.TestCase): self.obj = ProjectCommit(self.gl, data={"id": 3, "project_id": 2}) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/commits/3/diff", + path="/api/v4/projects/2/repository/commits/3/diff", method="get") def resp_diff(self, url, request): headers = {'content-type': 'application/json'} @@ -389,7 +389,7 @@ class TestProjectCommit(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/commits/3/diff", + path="/api/v4/projects/2/repository/commits/3/diff", method="get") def resp_diff_fail(self, url, request): headers = {'content-type': 'application/json'} @@ -397,7 +397,7 @@ class TestProjectCommit(unittest.TestCase): return response(400, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/blobs/3", + path="/api/v4/projects/2/repository/blobs/3", method="get") def resp_blob(self, url, request): headers = {'content-type': 'application/json'} @@ -405,7 +405,7 @@ class TestProjectCommit(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/repository/blobs/3", + path="/api/v4/projects/2/repository/blobs/3", method="get") def resp_blob_fail(self, url, request): headers = {'content-type': 'application/json'} @@ -440,7 +440,7 @@ class TestProjectSnippet(unittest.TestCase): self.obj = ProjectSnippet(self.gl, data={"id": 3, "project_id": 2}) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/snippets/3/raw", + path="/api/v4/projects/2/snippets/3/raw", method="get") def resp_content(self, url, request): headers = {'content-type': 'application/json'} @@ -448,7 +448,7 @@ class TestProjectSnippet(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/projects/2/snippets/3/raw", + path="/api/v4/projects/2/snippets/3/raw", method="get") def resp_content_fail(self, url, request): headers = {'content-type': 'application/json'} @@ -474,7 +474,7 @@ class TestSnippet(unittest.TestCase): self.obj = Snippet(self.gl, data={"id": 3}) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/snippets/3/raw", + path="/api/v4/snippets/3/raw", method="get") def resp_content(self, url, request): headers = {'content-type': 'application/json'} @@ -482,7 +482,7 @@ class TestSnippet(unittest.TestCase): return response(200, content, headers, None, 5, request) @urlmatch(scheme="http", netloc="localhost", - path="/api/v3/snippets/3/raw", + path="/api/v4/snippets/3/raw", method="get") def resp_content_fail(self, url, request): headers = {'content-type': 'application/json'} diff --git a/gitlab/tests/test_manager.py b/gitlab/tests/test_manager.py index 5cd3130..c6ef299 100644 --- a/gitlab/tests/test_manager.py +++ b/gitlab/tests/test_manager.py @@ -52,7 +52,8 @@ class TestGitlabManager(unittest.TestCase): def setUp(self): self.gitlab = Gitlab("http://localhost", private_token="private_token", email="testuser@test.com", - password="testpassword", ssl_verify=True) + password="testpassword", ssl_verify=True, + api_version=3) def test_set_parent_args(self): @urlmatch(scheme="http", netloc="localhost", path="/api/v3/fake", diff --git a/gitlab/tests/test_mixins.py b/gitlab/tests/test_mixins.py index e78c757..c51322a 100644 --- a/gitlab/tests/test_mixins.py +++ b/gitlab/tests/test_mixins.py @@ -434,89 +434,3 @@ class TestMixinMethods(unittest.TestCase): self.assertIsInstance(obj, FakeObject) self.assertEqual(obj.key, 'foo') self.assertEqual(obj.value, 'bar') - - -class TestExceptions(unittest.TestCase): - def setUp(self): - self.gl = Gitlab("http://localhost", private_token="private_token", - api_version=4) - - def test_get_mixin(self): - class M(GetMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabGetError, m.get, 1) - - def test_get_without_id_mixin(self): - class M(GetWithoutIdMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabGetError, m.get) - - def test_list_mixin(self): - class M(ListMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabListError, m.list) - - def test_get_from_list_mixin(self): - class M(GetFromListMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabListError, m.list) - self.assertRaises(GitlabGetError, m.get, 1) - - def test_create_mixin(self): - class M(CreateMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabCreateError, m.create, {}) - - def test_update_mixin(self): - class M(UpdateMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabUpdateError, m.update, 1, {}) - - def test_set_mixin(self): - class M(SetMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabSetError, m.set, 'foo', 'bar') - - def test_delete_mixin(self): - class M(DeleteMixin, FakeManager): - pass - - m = M(self.gl) - self.assertRaises(GitlabDeleteError, m.delete, 1) - - def test_object_mixin(self): - class M(UpdateMixin, DeleteMixin, FakeManager): - pass - - class O(SaveMixin, ObjectDeleteMixin, AccessRequestMixin, - SubscribableMixin, TodoMixin, TimeTrackingMixin, RESTObject): - pass - - mgr = M(self.gl) - obj = O(mgr, {'id': 42, 'foo': 'bar'}) - obj.foo = 'baz' - self.assertRaises(GitlabUpdateError, obj.save) - self.assertRaises(GitlabDeleteError, obj.delete) - self.assertRaises(GitlabUpdateError, obj.approve) - self.assertRaises(GitlabSubscribeError, obj.subscribe) - self.assertRaises(GitlabUnsubscribeError, obj.unsubscribe) - self.assertRaises(GitlabTodoError, obj.todo) - self.assertRaises(GitlabTimeTrackingError, obj.time_stats) - self.assertRaises(GitlabTimeTrackingError, obj.time_estimate, '1d') - self.assertRaises(GitlabTimeTrackingError, obj.reset_time_estimate) - self.assertRaises(GitlabTimeTrackingError, obj.add_spent_time, '1d') - self.assertRaises(GitlabTimeTrackingError, obj.reset_spent_time) diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 014714e..0db9dfd 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -934,7 +934,7 @@ class ProjectIssue(GitlabObject): {'project_id': self.project_id, 'issue_id': self.id}) r = self.gitlab._raw_post(url, **kwargs) - raise_error_from_response(r, GitlabSubscribeError) + raise_error_from_response(r, GitlabSubscribeError, 201) self._set_from_dict(r.json()) def unsubscribe(self, **kwargs): @@ -1496,18 +1496,6 @@ class ProjectFileManager(BaseManager): obj_cls = ProjectFile -class ProjectPipelineSchedule(GitlabObject): - _url = '/projects/%(project_id)s/pipeline_schedules' - _create_url = '/projects/%(project_id)s/pipeline_schedules' - - requiredUrlAttrs = ['project_id'] - requiredCreateAttrs = ['description', 'ref', 'cron'] - - -class ProjectPipelineSchedulesManager(BaseManager): - obj_cls = ProjectPipelineSchedule - - class ProjectPipeline(GitlabObject): _url = '/projects/%(project_id)s/pipelines' _create_url = '/projects/%(project_id)s/pipeline' @@ -1815,7 +1803,6 @@ class Project(GitlabObject): ('notificationsettings', 'ProjectNotificationSettingsManager', [('project_id', 'id')]), ('pipelines', 'ProjectPipelineManager', [('project_id', 'id')]), - ('pipeline_schedules', 'ProjectPipelineSchedulesManager', [('project_id', 'id')]), ('runners', 'ProjectRunnerManager', [('project_id', 'id')]), ('services', 'ProjectServiceManager', [('project_id', 'id')]), ('snippets', 'ProjectSnippetManager', [('project_id', 'id')]), diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 17e987c..aff5e17 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -112,6 +112,17 @@ class SidekiqManager(RESTManager): return self.gitlab.http_get('/sidekiq/compound_metrics', **kwargs) +class Event(RESTObject): + _id_attr = None + _short_print_attr = 'target_title' + + +class EventManager(ListMixin, RESTManager): + _path = '/events' + _obj_cls = Event + _list_filters = ('action', 'target_type', 'before', 'after', 'sort') + + class UserActivities(RESTObject): _id_attr = 'username' @@ -143,6 +154,16 @@ class UserEmailManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _create_attrs = (('email', ), tuple()) +class UserEvent(Event): + pass + + +class UserEventManager(EventManager): + _path = '/users/%(user_id)s/events' + _obj_cls = UserEvent + _from_parent_attrs = {'user_id': 'id'} + + class UserGPGKey(ObjectDeleteMixin, RESTObject): pass @@ -181,7 +202,7 @@ class UserProject(RESTObject): pass -class UserProjectManager(CreateMixin, RESTManager): +class UserProjectManager(ListMixin, CreateMixin, RESTManager): _path = '/projects/user/%(user_id)s' _obj_cls = UserProject _from_parent_attrs = {'user_id': 'id'} @@ -192,6 +213,31 @@ class UserProjectManager(CreateMixin, RESTManager): 'public', 'visibility', 'description', 'builds_enabled', 'public_builds', 'import_url', 'only_allow_merge_if_build_succeeds') ) + _list_filters = ('archived', 'visibility', 'order_by', 'sort', 'search', + 'simple', 'owned', 'membership', 'starred', 'statistics', + 'with_issues_enabled', 'with_merge_requests_enabled') + + def list(self, **kwargs): + """Retrieve a list of objects. + + Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list + **kwargs: Extra options to send to the Gitlab server (e.g. sudo) + + Returns: + list: The list of objects, or a generator if `as_list` is False + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the server cannot perform the request + """ + + path = '/users/%s/projects' % self._parent.id + return ListMixin.list(self, path=path, **kwargs) class User(SaveMixin, ObjectDeleteMixin, RESTObject): @@ -199,6 +245,7 @@ class User(SaveMixin, ObjectDeleteMixin, RESTObject): _managers = ( ('customattributes', 'UserCustomAttributeManager'), ('emails', 'UserEmailManager'), + ('events', 'UserEventManager'), ('gpgkeys', 'UserGPGKeyManager'), ('impersonationtokens', 'UserImpersonationTokenManager'), ('keys', 'UserKeyManager'), @@ -253,7 +300,7 @@ class UserManager(CRUDMixin, RESTManager): _obj_cls = User _list_filters = ('active', 'blocked', 'username', 'extern_uid', 'provider', - 'external', 'search') + 'external', 'search', 'custom_attributes') _create_attrs = ( tuple(), ('email', 'username', 'name', 'password', 'reset_password', 'skype', @@ -400,6 +447,38 @@ class DockerfileManager(RetrieveMixin, RESTManager): _obj_cls = Dockerfile +class Feature(RESTObject): + _id_attr = 'name' + + +class FeatureManager(ListMixin, RESTManager): + _path = '/features/' + _obj_cls = Feature + + @exc.on_http_error(exc.GitlabSetError) + def set(self, name, value, feature_group=None, user=None, **kwargs): + """Create or update the object. + + Args: + name (str): The value to set for the object + value (bool/int): The value to set for the object + feature_group (str): A feature group name + user (str): A GitLab username + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabSetError: If an error occured + + Returns: + obj: The created/updated attribute + """ + path = '%s/%s' % (self.path, name.replace('/', '%2F')) + data = {'value': value, 'feature_group': feature_group, 'user': user} + server_data = self.gitlab.http_post(path, post_data=data, **kwargs) + return self._obj_cls(self, server_data) + + class Gitignore(RESTObject): _id_attr = 'name' @@ -429,6 +508,17 @@ class GroupAccessRequestManager(GetFromListMixin, CreateMixin, DeleteMixin, _from_parent_attrs = {'group_id': 'id'} +class GroupCustomAttribute(ObjectDeleteMixin, RESTObject): + _id_attr = 'key' + + +class GroupCustomAttributeManager(RetrieveMixin, SetMixin, DeleteMixin, + RESTManager): + _path = '/groups/%(group_id)s/custom_attributes' + _obj_cls = GroupCustomAttribute + _from_parent_attrs = {'group_id': 'id'} + + class GroupIssue(RESTObject): pass @@ -470,6 +560,11 @@ class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List issues related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -494,6 +589,11 @@ class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List the merge requests related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -544,6 +644,18 @@ class GroupProjectManager(GetFromListMixin, RESTManager): 'ci_enabled_first') +class GroupSubgroup(RESTObject): + pass + + +class GroupSubgroupManager(GetFromListMixin, RESTManager): + _path = '/groups/%(group_id)s/subgroups' + _obj_cls = GroupSubgroup + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('skip_groups', 'all_available', 'search', 'order_by', + 'sort', 'statistics', 'owned') + + class GroupVariable(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'key' @@ -560,11 +672,13 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'name' _managers = ( ('accessrequests', 'GroupAccessRequestManager'), + ('customattributes', 'GroupCustomAttributeManager'), + ('issues', 'GroupIssueManager'), ('members', 'GroupMemberManager'), ('milestones', 'GroupMilestoneManager'), ('notificationsettings', 'GroupNotificationSettingsManager'), ('projects', 'GroupProjectManager'), - ('issues', 'GroupIssueManager'), + ('subgroups', 'GroupSubgroupManager'), ('variables', 'GroupVariableManager'), ) @@ -588,6 +702,8 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): class GroupManager(CRUDMixin, RESTManager): _path = '/groups' _obj_cls = Group + _list_filters = ('skip_groups', 'all_available', 'search', 'order_by', + 'sort', 'statistics', 'owned', 'custom_attributes') _create_attrs = ( ('name', 'path'), ('description', 'visibility', 'parent_id', 'lfs_enabled', @@ -698,6 +814,15 @@ class NamespaceManager(GetFromListMixin, RESTManager): _list_filters = ('search', ) +class PagesDomain(RESTObject): + _id_attr = 'domain' + + +class PagesDomainManager(ListMixin, RESTManager): + _path = '/pages/domains' + _obj_cls = PagesDomain + + class ProjectBoardList(SaveMixin, ObjectDeleteMixin, RESTObject): pass @@ -773,6 +898,17 @@ class ProjectBranchManager(NoUpdateMixin, RESTManager): _create_attrs = (('branch', 'ref'), tuple()) +class ProjectCustomAttribute(ObjectDeleteMixin, RESTObject): + _id_attr = 'key' + + +class ProjectCustomAttributeManager(RetrieveMixin, SetMixin, DeleteMixin, + RESTManager): + _path = '/projects/%(project_id)s/custom_attributes' + _obj_cls = ProjectCustomAttribute + _from_parent_attrs = {'project_id': 'id'} + + class ProjectJob(RESTObject): @cli.register_custom_action('ProjectJob') @exc.on_http_error(exc.GitlabJobCancelError) @@ -1047,12 +1183,11 @@ class ProjectKeyManager(NoUpdateMixin, RESTManager): self.gitlab.http_post(path, **kwargs) -class ProjectEvent(RESTObject): - _id_attr = None - _short_print_attr = 'target_title' +class ProjectEvent(Event): + pass -class ProjectEventManager(ListMixin, RESTManager): +class ProjectEventManager(EventManager): _path = '/projects/%(project_id)s/events' _obj_cls = ProjectEvent _from_parent_attrs = {'project_id': 'id'} @@ -1091,10 +1226,35 @@ class ProjectHookManager(CRUDMixin, RESTManager): ) -class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject): +class ProjectIssueAwardEmoji(ObjectDeleteMixin, RESTObject): + pass + + +class ProjectIssueAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/issues/%(issue_iid)s/award_emoji' + _obj_cls = ProjectIssueAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} + _create_attrs = (('name', ), tuple()) + + +class ProjectIssueNoteAwardEmoji(ObjectDeleteMixin, RESTObject): pass +class ProjectIssueNoteAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = ('/projects/%(project_id)s/issues/%(issue_iid)s' + '/notes/%(note_id)s/award_emoji') + _obj_cls = ProjectIssueNoteAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', + 'issue_iid': 'issue_iid', + 'note_id': 'id'} + _create_attrs = (('name', ), tuple()) + + +class ProjectIssueNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('awardemojis', 'ProjectIssueNoteAwardEmojiManager'),) + + class ProjectIssueNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/issues/%(issue_iid)s/notes' _obj_cls = ProjectIssueNote @@ -1107,7 +1267,10 @@ class ProjectIssue(SubscribableMixin, TodoMixin, TimeTrackingMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' _id_attr = 'iid' - _managers = (('notes', 'ProjectIssueNoteManager'), ) + _managers = ( + ('notes', 'ProjectIssueNoteManager'), + ('awardemojis', 'ProjectIssueAwardEmojiManager'), + ) @cli.register_custom_action('ProjectIssue') @exc.on_http_error(exc.GitlabUpdateError) @@ -1196,6 +1359,18 @@ class ProjectNotificationSettingsManager(NotificationSettingsManager): _from_parent_attrs = {'project_id': 'id'} +class ProjectPagesDomain(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = 'domain' + + +class ProjectPagesDomainManager(CRUDMixin, RESTManager): + _path = '/projects/%(project_id)s/pages/domains' + _obj_cls = ProjectPagesDomain + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('domain', ), ('certificate', 'key')) + _update_attrs = (tuple(), ('certificate', 'key')) + + class ProjectTag(ObjectDeleteMixin, RESTObject): _id_attr = 'name' _short_print_attr = 'name' @@ -1243,6 +1418,17 @@ class ProjectTagManager(NoUpdateMixin, RESTManager): _create_attrs = (('tag_name', 'ref'), ('message',)) +class ProjectMergeRequestAwardEmoji(ObjectDeleteMixin, RESTObject): + pass + + +class ProjectMergeRequestAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/award_emoji' + _obj_cls = ProjectMergeRequestAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} + _create_attrs = (('name', ), tuple()) + + class ProjectMergeRequestDiff(RESTObject): pass @@ -1253,10 +1439,24 @@ class ProjectMergeRequestDiffManager(RetrieveMixin, RESTManager): _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} -class ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject): +class ProjectMergeRequestNoteAwardEmoji(ObjectDeleteMixin, RESTObject): pass +class ProjectMergeRequestNoteAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s' + '/notes/%(note_id)s/award_emoji') + _obj_cls = ProjectMergeRequestNoteAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', + 'mr_iid': 'issue_iid', + 'note_id': 'id'} + _create_attrs = (('name', ), tuple()) + + +class ProjectMergeRequestNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('awardemojis', 'ProjectMergeRequestNoteAwardEmojiManager'),) + + class ProjectMergeRequestNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/merge_requests/%(mr_iid)s/notes' _obj_cls = ProjectMergeRequestNote @@ -1270,8 +1470,9 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, _id_attr = 'iid' _managers = ( + ('awardemojis', 'ProjectMergeRequestAwardEmojiManager'), + ('diffs', 'ProjectMergeRequestDiffManager'), ('notes', 'ProjectMergeRequestNoteManager'), - ('diffs', 'ProjectMergeRequestDiffManager') ) @cli.register_custom_action('ProjectMergeRequest') @@ -1299,6 +1500,11 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, """List issues that will close on merge." Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -1321,6 +1527,11 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, """List the merge request commits. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -1392,6 +1603,30 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, **kwargs) self._update_attrs(server_data) + @cli.register_custom_action('ProjectMergeRequest') + @exc.on_http_error(exc.GitlabListError) + def participants(self, **kwargs): + """List the merge request participants. + + Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list + **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 participants + """ + + path = '%s/%s/participants' % (self.manager.path, self.get_id()) + return self.manager.gitlab.http_get(path, **kwargs) + class ProjectMergeRequestManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/merge_requests' @@ -1423,6 +1658,11 @@ class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List issues related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -1447,6 +1687,11 @@ class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): """List the merge requests related to this milestone. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -1625,9 +1870,10 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, """ self._check_missing_create_attrs(data) - file_path = data.pop('file_path').replace('/', '%2F') + new_data = data.copy() + file_path = new_data.pop('file_path').replace('/', '%2F') path = '%s/%s' % (self.path, file_path) - server_data = self.gitlab.http_post(path, post_data=data, **kwargs) + server_data = self.gitlab.http_post(path, post_data=new_data, **kwargs) return self._obj_cls(self, server_data) @exc.on_http_error(exc.GitlabUpdateError) @@ -1706,22 +1952,8 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, return utils.response_content(result, streamed, action, chunk_size) -class ProjectPipelineJob(ProjectJob): - pass - - -class ProjectPipelineJobsManager(ListMixin, RESTManager): - _path = '/projects/%(project_id)s/pipelines/%(pipeline_id)s/jobs' - _obj_cls = ProjectPipelineJob - _from_parent_attrs = {'project_id': 'project_id', - 'pipeline_id' : 'id'} - _list_filters = ('scope',) - - class ProjectPipeline(RESTObject): - _managers = ( - ('jobs', 'ProjectPipelineJobsManager'), - ) + _managers = (('jobs', 'ProjectPipelineJobManager'), ) @cli.register_custom_action('ProjectPipeline') @exc.on_http_error(exc.GitlabPipelineCancelError) @@ -1784,65 +2016,79 @@ class ProjectPipelineScheduleVariable(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = 'key' -class ProjectPipelineScheduleVariableManager(CRUDMixin, RESTManager): - _path = '/projects/%(project_id)s/pipeline_schedules/%(pipeline_schedule_id)s/variables' +class ProjectPipelineScheduleVariableManager(CreateMixin, UpdateMixin, + DeleteMixin, RESTManager): + _path = ('/projects/%(project_id)s/pipeline_schedules/' + '%(pipeline_schedule_id)s/variables') _obj_cls = ProjectPipelineScheduleVariable _from_parent_attrs = {'project_id': 'project_id', 'pipeline_schedule_id' : 'id'} - _create_attrs = (('pipeline_schedule_id', 'key', 'value'), tuple()) _create_attrs = (('key', 'value'), tuple()) + _update_attrs = (('key', 'value'), tuple()) - def list(self): - array = [] - if 'variables' in self._parent._attrs: - for variable in self._parent._attrs['variables']: - schedule_variable = self._obj_cls(self, variable) - array.append(schedule_variable) - else: - obj = self._parent.manager.get(self._parent.id) - for variable in obj._attrs['variables']: - schedule_variable = self._obj_cls(self, variable) - array.append(schedule_variable) - return array +class ProjectPipelineSchedule(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('variables', 'ProjectPipelineScheduleVariableManager'),) + @cli.register_custom_action('ProjectPipelineSchedule') + @exc.on_http_error(exc.GitlabOwnershipError) + def take_ownership(self, **kwargs): + """Update the owner of a pipeline schedule. -class ProjectPipelineSchedule(RESTObject): - _managers = ( - ('variables', 'ProjectPipelineScheduleVariableManager'), - ) + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabOwnershipError: If the request failed + """ + path = '%s/%s/take_ownership' % (self.manager.path, self.get_id()) + server_data = self.manager.gitlab.http_post(path, **kwargs) + self._update_attrs(server_data) -class ProjectPipelineSchedulesManager(RetrieveMixin, CreateMixin, RESTManager): + +class ProjectPipelineScheduleManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/pipeline_schedules' _obj_cls = ProjectPipelineSchedule _from_parent_attrs = {'project_id': 'id'} _create_attrs = (('description', 'ref', 'cron'), ('cron_timezone', 'active')) + _update_attrs = (tuple(), + ('description', 'ref', 'cron', 'cron_timezone', 'active')) - def create(self, data, **kwargs): - """Creates a new object. - Args: - data (dict): Parameters to send to the server to create the - resource - **kwargs: Extra options to send to the server (e.g. sudo) +class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject): + pass - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabCreateError: If the server cannot perform the request - Returns: - RESTObject: A new instance of the managed object class build with - the data sent by the server - """ - return CreateMixin.create(self, data, path=self.path, **kwargs) +class ProjectPipelineJob(ProjectJob): + pass -class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject): +class ProjectPipelineJobManager(GetFromListMixin, RESTManager): + _path = '/projects/%(project_id)s/pipelines/%(pipeline_id)s/jobs' + _obj_cls = ProjectPipelineJob + _from_parent_attrs = {'project_id': 'project_id', 'pipeline_id': 'id'} + + +class ProjectSnippetNoteAwardEmoji(ObjectDeleteMixin, RESTObject): pass +class ProjectSnippetNoteAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = ('/projects/%(project_id)s/snippets/%(snippet_id)s' + '/notes/%(note_id)s/award_emoji') + _obj_cls = ProjectSnippetNoteAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', + 'snippet_id': 'snippet_id', + 'note_id': 'id'} + _create_attrs = (('name', ), tuple()) + + +class ProjectSnippetNote(SaveMixin, ObjectDeleteMixin, RESTObject): + _managers = (('awardemojis', 'ProjectSnippetNoteAwardEmojiManager'),) + + class ProjectSnippetNoteManager(CRUDMixin, RESTManager): _path = '/projects/%(project_id)s/snippets/%(snippet_id)s/notes' _obj_cls = ProjectSnippetNote @@ -1852,10 +2098,24 @@ class ProjectSnippetNoteManager(CRUDMixin, RESTManager): _update_attrs = (('body', ), tuple()) +class ProjectSnippetAwardEmoji(ObjectDeleteMixin, RESTObject): + pass + + +class ProjectSnippetAwardEmojiManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/snippets/%(snippet_id)s/award_emoji' + _obj_cls = ProjectSnippetAwardEmoji + _from_parent_attrs = {'project_id': 'project_id', 'snippet_id': 'id'} + _create_attrs = (('name', ), tuple()) + + class ProjectSnippet(SaveMixin, ObjectDeleteMixin, RESTObject): _url = '/projects/%(project_id)s/snippets' _short_print_attr = 'title' - _managers = (('notes', 'ProjectSnippetNoteManager'), ) + _managers = ( + ('awardemojis', 'ProjectSnippetAwardEmojiManager'), + ('notes', 'ProjectSnippetNoteManager'), + ) @cli.register_custom_action('ProjectSnippet') @exc.on_http_error(exc.GitlabGetError) @@ -1895,8 +2155,17 @@ class ProjectSnippetManager(CRUDMixin, RESTManager): class ProjectTrigger(SaveMixin, ObjectDeleteMixin, RESTObject): @cli.register_custom_action('ProjectTrigger') + @exc.on_http_error(exc.GitlabOwnershipError) def take_ownership(self, **kwargs): - """Update the owner of a trigger.""" + """Update the owner of a trigger. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabOwnershipError: If the request failed + """ path = '%s/%s/take_ownership' % (self.manager.path, self.get_id()) server_data = self.manager.gitlab.http_post(path, **kwargs) self._update_attrs(server_data) @@ -2094,6 +2363,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('branches', 'ProjectBranchManager'), ('jobs', 'ProjectJobManager'), ('commits', 'ProjectCommitManager'), + ('customattributes', 'ProjectCustomAttributeManager'), ('deployments', 'ProjectDeploymentManager'), ('environments', 'ProjectEnvironmentManager'), ('events', 'ProjectEventManager'), @@ -2108,9 +2378,10 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('milestones', 'ProjectMilestoneManager'), ('notes', 'ProjectNoteManager'), ('notificationsettings', 'ProjectNotificationSettingsManager'), + ('pagesdomains', 'ProjectPagesDomainManager'), ('pipelines', 'ProjectPipelineManager'), ('protectedbranches', 'ProjectProtectedBranchManager'), - ('pipeline_schedules', 'ProjectPipelineSchedulesManager'), + ('pipelineschedules', 'ProjectPipelineScheduleManager'), ('runners', 'ProjectRunnerManager'), ('services', 'ProjectServiceManager'), ('snippets', 'ProjectSnippetManager'), @@ -2129,6 +2400,11 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): Args: path (str): Path of the top folder (/ by default) ref (str): Reference to a commit or branch + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -2223,6 +2499,11 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): """Return a list of contributors for the project. Args: + all (bool): If True, return all the items, without pagination + per_page (int): Number of items to retrieve per request + page (int): ID of the page to return (starts with page 1) + as_list (bool): If set to False and no pagination option is + defined, return a generator instead of a list **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -2499,7 +2780,8 @@ class ProjectManager(CRUDMixin, RESTManager): ) _list_filters = ('search', 'owned', 'starred', 'archived', 'visibility', 'order_by', 'sort', 'simple', 'membership', 'statistics', - 'with_issues_enabled', 'with_merge_requests_enabled') + 'with_issues_enabled', 'with_merge_requests_enabled', + 'custom_attributes') class Runner(SaveMixin, ObjectDeleteMixin, RESTObject): @@ -34,6 +34,13 @@ setup(name='python-gitlab', 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 'Natural Language :: English', 'Operating System :: POSIX', - 'Operating System :: Microsoft :: Windows' + 'Operating System :: Microsoft :: Windows', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ] ) diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index 31651b3..7e149f6 100755 --- a/tools/build_test_env.sh +++ b/tools/build_test_env.sh @@ -94,6 +94,21 @@ testcase() { OK } +if [ -z "$NOVENV" ]; then + log "Creating Python virtualenv..." + try "$VENV_CMD" "$VENV" + . "$VENV"/bin/activate || fatal "failed to activate Python virtual environment" + + log "Installing dependencies into virtualenv..." + try pip install -rrequirements.txt + + log "Installing into virtualenv..." + try pip install -e . + + # to run generate_token.py + pip install bs4 lxml +fi + log "Waiting for gitlab to come online... " I=0 while :; do @@ -107,23 +122,7 @@ while :; do done # Get the token -log "Getting GitLab token..." -I=0 -while :; do - sleep 1 - TOKEN_JSON=$( - try curl -s http://localhost:8080/api/v3/session \ - -X POST \ - --data "login=$LOGIN&password=$PASSWORD" - ) >/dev/null 2>&1 || true - TOKEN=$( - pecho "${TOKEN_JSON}" | - try python -c \ - 'import sys, json; print(json.load(sys.stdin)["private_token"])' - ) >/dev/null 2>&1 && break - I=$((I+1)) - [ "$I" -lt 20 ] || fatal "timed out" -done +TOKEN=$($(dirname $0)/generate_token.py) cat > $CONFIG << EOF [global] @@ -139,18 +138,6 @@ EOF log "Config file content ($CONFIG):" log <$CONFIG -if [ -z "$NOVENV" ]; then - log "Creating Python virtualenv..." - try "$VENV_CMD" "$VENV" - . "$VENV"/bin/activate || fatal "failed to activate Python virtual environment" - - log "Installing dependencies into virtualenv..." - try pip install -rrequirements.txt - - log "Installing into virtualenv..." - try pip install -e . -fi - log "Pausing to give GitLab some time to finish starting up..." sleep 30 diff --git a/tools/generate_token.py b/tools/generate_token.py new file mode 100755 index 0000000..ab14188 --- /dev/null +++ b/tools/generate_token.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python + +import sys +try: + from urllib.parse import urljoin +except ImportError: + from urlparse import urljoin + +from bs4 import BeautifulSoup +import requests + +endpoint = "http://localhost:8080" +root_route = urljoin(endpoint, "/") +sign_in_route = urljoin(endpoint, "/users/sign_in") +pat_route = urljoin(endpoint, "/profile/personal_access_tokens") + +login = "root" +password = "5iveL!fe" + + +def find_csrf_token(text): + soup = BeautifulSoup(text, "lxml") + token = soup.find(attrs={"name": "csrf-token"}) + param = soup.find(attrs={"name": "csrf-param"}) + data = {param.get("content"): token.get("content")} + return data + + +def obtain_csrf_token(): + r = requests.get(root_route) + token = find_csrf_token(r.text) + return token, r.cookies + + +def sign_in(csrf, cookies): + data = { + "user[login]": login, + "user[password]": password, + } + data.update(csrf) + r = requests.post(sign_in_route, data=data, cookies=cookies) + token = find_csrf_token(r.text) + return token, r.history[0].cookies + + +def obtain_personal_access_token(name, csrf, cookies): + data = { + "personal_access_token[name]": name, + "personal_access_token[scopes][]": ["api", "sudo"], + } + data.update(csrf) + r = requests.post(pat_route, data=data, cookies=cookies) + soup = BeautifulSoup(r.text, "lxml") + token = soup.find('input', id='created-personal-access-token').get('value') + return token + + +def main(): + csrf1, cookies1 = obtain_csrf_token() + csrf2, cookies2 = sign_in(csrf1, cookies1) + + token = obtain_personal_access_token('default', csrf2, cookies2) + print(token) + + +if __name__ == "__main__": + main() diff --git a/tools/python_test_v3.py b/tools/python_test_v3.py index 00faccc..c16bb40 100644 --- a/tools/python_test_v3.py +++ b/tools/python_test_v3.py @@ -21,14 +21,8 @@ DEPLOY_KEY = ("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFdRyjJQh+1niBpXqE2I8dzjG" "rke9IepE7SPBT41C+YtUX4dfDZDmczM1cE0YL/krdUCfuZHMa4ZS2YyNd6slufc" "vn bar@foo") -# login/password authentication -gl = gitlab.Gitlab('http://localhost:8080', email=LOGIN, password=PASSWORD) -gl.auth() -token_from_auth = gl.private_token - # token authentication from config file gl = gitlab.Gitlab.from_config(config_files=['/tmp/python-gitlab.cfg']) -assert(token_from_auth == gl.private_token) gl.auth() assert(isinstance(gl.user, gitlab.v3.objects.CurrentUser)) @@ -105,7 +99,7 @@ p_id = gl.groups.search('group2')[0].id group3 = gl.groups.create({'name': 'group3', 'path': 'group3', 'parent_id': p_id}) assert(len(gl.groups.list()) == 3) -assert(len(gl.groups.search("1")) == 1) +assert(len(gl.groups.search("oup1")) == 1) assert(group3.parent_id == p_id) group1.members.create({'access_level': gitlab.Group.OWNER_ACCESS, diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index f9ef83a..695722f 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -51,14 +51,8 @@ qG2ZdhHHmSK2LaQLFiSprUkikStNU9BqSQ== -----END PGP PUBLIC KEY BLOCK-----''' -# login/password authentication -gl = gitlab.Gitlab('http://localhost:8080', email=LOGIN, password=PASSWORD) -gl.auth() -token_from_auth = gl.private_token - # token authentication from config file gl = gitlab.Gitlab.from_config(config_files=['/tmp/python-gitlab.cfg']) -assert(token_from_auth == gl.private_token) gl.auth() assert(isinstance(gl.user, gitlab.v4.objects.CurrentUser)) @@ -98,6 +92,12 @@ assert(new_user.email == user.email) new_user.block() new_user.unblock() +# user projects list +assert(len(new_user.projects.list()) == 0) + +# events list +new_user.events.list() + foobar_user = gl.users.create( {'email': 'foobar@example.com', 'username': 'foobar', 'name': 'Foo Bar', 'password': 'foobar_password'}) @@ -135,6 +135,7 @@ assert(len(new_user.emails.list()) == 0) attrs = new_user.customattributes.list() assert(len(attrs) == 0) attr = new_user.customattributes.set('key', 'value1') +assert(len(gl.users.list(custom_attributes={'key': 'value1'})) == 1) assert(attr.key == 'key') assert(attr.value == 'value1') assert(len(new_user.customattributes.list()) == 1) @@ -210,8 +211,9 @@ p_id = gl.groups.list(search='group2')[0].id group3 = gl.groups.create({'name': 'group3', 'path': 'group3', 'parent_id': p_id}) assert(len(gl.groups.list()) == 3) -assert(len(gl.groups.list(search='1')) == 1) +assert(len(gl.groups.list(search='oup1')) == 1) assert(group3.parent_id == p_id) +assert(group2.subgroups.list()[0].id == group3.id) group1.members.create({'access_level': gitlab.Group.OWNER_ACCESS, 'user_id': user1.id}) @@ -235,6 +237,21 @@ assert(member.access_level == gitlab.Group.OWNER_ACCESS) group2.members.delete(gl.user.id) +# group custom attributes +attrs = group2.customattributes.list() +assert(len(attrs) == 0) +attr = group2.customattributes.set('key', 'value1') +assert(len(gl.groups.list(custom_attributes={'key': 'value1'})) == 1) +assert(attr.key == 'key') +assert(attr.value == 'value1') +assert(len(group2.customattributes.list()) == 1) +attr = group2.customattributes.set('key', 'value2') +attr = group2.customattributes.get('key') +assert(attr.value == 'value2') +assert(len(group2.customattributes.list()) == 1) +attr.delete() +assert(len(group2.customattributes.list()) == 0) + # group notification settings settings = group2.notificationsettings.get() settings.level = 'disabled' @@ -290,6 +307,30 @@ assert(len(l1) == 1) assert(len(l2) == 1) assert(l1[0].id != l2[0].id) +# group custom attributes +attrs = admin_project.customattributes.list() +assert(len(attrs) == 0) +attr = admin_project.customattributes.set('key', 'value1') +assert(len(gl.projects.list(custom_attributes={'key': 'value1'})) == 1) +assert(attr.key == 'key') +assert(attr.value == 'value1') +assert(len(admin_project.customattributes.list()) == 1) +attr = admin_project.customattributes.set('key', 'value2') +attr = admin_project.customattributes.get('key') +assert(attr.value == 'value2') +assert(len(admin_project.customattributes.list()) == 1) +attr.delete() +assert(len(admin_project.customattributes.list()) == 0) + +# project pages domains +domain = admin_project.pagesdomains.create({'domain': 'foo.domain.com'}) +assert(len(admin_project.pagesdomains.list()) == 1) +assert(len(gl.pagesdomains.list()) == 1) +domain = admin_project.pagesdomains.get('foo.domain.com') +assert(domain.domain == 'foo.domain.com') +domain.delete() +assert(len(admin_project.pagesdomains.list()) == 0) + # project content (files) admin_project.files.create({'file_path': 'README', 'branch': 'master', @@ -370,7 +411,7 @@ assert(env.external_url == 'http://new.env/whatever') env.delete() assert(len(admin_project.environments.list()) == 0) -# events +# project events admin_project.events.list() # forks @@ -436,6 +477,10 @@ assert(len(admin_project.issues.list(milestone='milestone1')) == 1) assert(m1.issues().next().title == 'my issue 1') note = issue1.notes.create({'body': 'This is an issue note'}) assert(len(issue1.notes.list()) == 1) +emoji = note.awardemojis.create({'name': 'tractor'}) +assert(len(note.awardemojis.list()) == 1) +emoji.delete() +assert(len(note.awardemojis.list()) == 0) note.delete() assert(len(issue1.notes.list()) == 0) assert(isinstance(issue1.user_agent_detail(), dict)) @@ -488,6 +533,12 @@ admin_project.files.create({'file_path': 'README2.rst', mr = admin_project.mergerequests.create({'source_branch': 'branch1', 'target_branch': 'master', 'title': 'MR readme2'}) + +# basic testing: only make sure that the methods exist +mr.commits() +mr.changes() +#mr.participants() # not yet available + mr.merge() admin_project.branches.delete('branch1') @@ -543,6 +594,11 @@ assert(len(ns) != 0) ns = gl.namespaces.list(search='root', all=True)[0] assert(ns.kind == 'user') +# features +feat = gl.features.set('foo', 30) +assert(feat.name == 'foo') +assert(len(gl.features.list()) == 1) + # broadcast messages msg = gl.broadcastmessages.create({'message': 'this is the message'}) msg.color = '#444444' @@ -587,3 +643,6 @@ snippet.delete() # user activities gl.user_activities.list() + +# events +gl.events.list() |