diff --git a/AUTHORS b/AUTHORS index ac5d28fac..c0bc7d6b5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,7 +36,6 @@ Erik Weatherwax fgouteroux Greg Allen Guillaume Delacour -Guyzmo Guyzmo hakkeroid Ian Sparks @@ -51,6 +50,7 @@ Jerome Robert Johan Brandhorst Jonathon Reinhart Jon Banafato +Keith Wansbrough Koen Smets Kris Gambirazzi Lyudmil Nenov @@ -59,11 +59,14 @@ massimone88 Matej Zerovnik Matt Odden Maura Hausman +Michael Overmeyer Michal Galet Mike Kobit Mikhail Lopotkov +Miouge1 Missionrulz Mond WAN +Moritz Lipp Nathan Giesbrecht Nathan Schmidt pa4373 @@ -74,6 +77,7 @@ Pete Browne Peter Mosmans P. F. Chimento Philipp Busch +Pierre Tardy Rafael Eyng Richard Hansen Robert Lu diff --git a/ChangeLog.rst b/ChangeLog.rst index 3049b9a0f..f1a45f27c 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,6 +1,24 @@ ChangeLog ========= +Version 1.3.0_ - 2018-02-18 +--------------------------- + +* Add support for pipeline schedules and schedule variables +* Clarify information about supported python version +* Add manager for jobs within a pipeline +* Fix wrong tag example +* Update the groups documentation +* Add support for MR participants API +* Add support for getting list of user projects +* Add Gitlab and User events support +* Make trigger_pipeline return the pipeline +* Config: support api_version in the global section +* Gitlab can be used as context manager +* Default to API v4 +* Add a simplified example for streamed artifacts +* Add documentation about labels update + Version 1.2.0_ - 2018-01-01 --------------------------- @@ -535,6 +553,7 @@ Version 0.1 - 2013-07-08 * Initial release +.. _1.3.0: https://github.com/python-gitlab/python-gitlab/compare/1.2.0...1.3.0 .. _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 diff --git a/README.rst b/README.rst index cce2ad0e3..652b79f8e 100644 --- a/README.rst +++ b/README.rst @@ -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 707b90d5f..da2545fe7 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -4,6 +4,12 @@ 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 ======================= diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 6879856b5..f2e72e20c 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -15,6 +15,7 @@ API examples gl_objects/deploy_keys gl_objects/deployments gl_objects/environments + gl_objects/events gl_objects/features gl_objects/groups gl_objects/issues diff --git a/docs/api-usage.rst b/docs/api-usage.rst index 3704591e8..190482f6f 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 @@ -63,21 +63,19 @@ 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 ======== @@ -274,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 f75a46a06..591761cae 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 ba4b22bff..0f616e842 100644 --- a/docs/gl_objects/builds.py +++ b/docs/gl_objects/builds.py @@ -55,16 +55,24 @@ 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() # end artifacts -# stream artifacts +# stream artifacts with class class Foo(object): def __init__(self): self._fd = open('artifacts.zip', 'wb') @@ -75,7 +83,15 @@ def __call__(self, chunk): target = Foo() build_or_job.artifacts(streamed=True, action=target) del(target) # flushes data on disk -# end stream artifacts +# end stream artifacts with class + +# stream artifacts with unzip +zipfn = "___artifacts.zip" +with open(zipfn, "wb") as f: + build_or_job.artifacts(streamed=True, action=f.write) +subprocess.run(["unzip", "-bo", zipfn]) +os.unlink(zipfn) +# end stream artifacts with unzip # keep artifacts build_or_job.keep_artifacts() diff --git a/docs/gl_objects/builds.rst b/docs/gl_objects/builds.rst index 1c95eb16e..2791188eb 100644 --- a/docs/gl_objects/builds.rst +++ b/docs/gl_objects/builds.rst @@ -1,10 +1,56 @@ -############################### -Jobs (v4 API) / Builds (v3 API) -############################### +########################## +Pipelines, Builds and Jobs +########################## Build and job are two classes representing the same object. Builds are used in v3 API, jobs in v4 API. +Project pipelines +================= + +A pipeline is a group of jobs executed by GitLab CI. + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectPipeline` + + :class:`gitlab.v4.objects.ProjectPipelineManager` + + :attr:`gitlab.v4.objects.Project.pipelines` + +* v3 API: + + + :class:`gitlab.v3.objects.ProjectPipeline` + + :class:`gitlab.v3.objects.ProjectPipelineManager` + + :attr:`gitlab.v3.objects.Project.pipelines` + + :attr:`gitlab.Gitlab.project_pipelines` + +* GitLab API: https://docs.gitlab.com/ce/api/pipelines.html + +Examples +-------- + +List pipelines for a project:: + + pipelines = project.pipelines.list() + +Get a pipeline for a project:: + + pipeline = project.pipelines.get(pipeline_id) + +Create a pipeline for a particular reference:: + + pipeline = project.pipelines.create({'ref': 'master'}) + +Retry the failed builds for a pipeline:: + + pipeline.retry() + +Cancel builds in a pipeline:: + + pipeline.cancel() + Triggers ======== @@ -56,6 +102,66 @@ Remove a trigger: :start-after: # trigger delete :end-before: # end trigger delete +Pipeline schedule +================= + +You can schedule pipeline runs using a cron-like syntax. Variables can be +associated with the scheduled pipelines. + +Reference +--------- + +* v4 API + + + :class:`gitlab.v4.objects.ProjectPipelineSchedule` + + :class:`gitlab.v4.objects.ProjectPipelineScheduleManager` + + :attr:`gitlab.v4.objects.Project.pipelineschedules` + + :class:`gitlab.v4.objects.ProjectPipelineScheduleVariable` + + :class:`gitlab.v4.objects.ProjectPipelineScheduleVariableManager` + + :attr:`gitlab.v4.objects.Project.pipelineschedules` + +* GitLab API: https://docs.gitlab.com/ce/api/pipeline_schedules.html + +Examples +-------- + +List pipeline schedules:: + + scheds = project.pipelineschedules.list() + +Get a single schedule:: + + sched = projects.pipelineschedules.get(schedule_id) + +Create a new schedule:: + + sched = project.pipelineschedules.create({ + 'ref': 'master', + 'description': 'Daily test', + 'cron': '0 1 * * *'}) + +Update a schedule:: + + sched.cron = '1 2 * * *' + sched.save() + +Delete a schedule:: + + sched.delete() + +Create a schedule variable:: + + var = sched.variables.create({'key': 'foo', 'value': 'bar'}) + +Edit a schedule variable:: + + var.value = 'new_value' + var.save() + +Delete a schedule variable:: + + var.delete() + Projects and groups variables ============================= @@ -122,8 +228,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 +276,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: @@ -191,8 +307,14 @@ You can download artifacts as a stream. Provide a callable to handle the stream: .. literalinclude:: builds.py - :start-after: # stream artifacts - :end-before: # end stream artifacts + :start-after: # stream artifacts with class + :end-before: # end stream artifacts with class + +In this second example, you can directly stream the output into a file, and unzip it afterwards: + +.. literalinclude:: builds.py + :start-after: # stream artifacts with unzip + :end-before: # end stream artifacts with unzip Mark a job artifact as kept when expiration is set: diff --git a/docs/gl_objects/events.rst b/docs/gl_objects/events.rst new file mode 100644 index 000000000..807dcad4b --- /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 +`_. + +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/groups.py b/docs/gl_objects/groups.py deleted file mode 100644 index f1a2a8f60..000000000 --- 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 5536de2ca..493f5d0ba 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,20 @@ 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 ========= @@ -91,6 +82,12 @@ 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 ======================= @@ -164,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/mrs.py b/docs/gl_objects/mrs.py index 1e54c80bb..7e11cc312 100644 --- a/docs/gl_objects/mrs.py +++ b/docs/gl_objects/mrs.py @@ -19,6 +19,7 @@ # update mr.description = 'New description' +mr.labels = ['foo', 'bar'] mr.save() # end update diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index 1790cc825..1b0a6b95d 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -33,6 +33,7 @@ # 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.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 @@ # end tags list # tags get -tags = project.tags.list('1.0') +tag = project.tags.get('1.0') # end tags get # tags create @@ -326,26 +321,6 @@ service.delete() # end service delete -# pipeline list -pipelines = project.pipelines.list() -# end pipeline list - -# pipeline get -pipeline = project.pipelines.get(pipeline_id) -# end pipeline get - -# pipeline create -pipeline = project.pipelines.create({'ref': 'master'}) -# end pipeline create - -# pipeline retry -pipeline.retry() -# end pipeline retry - -# pipeline cancel -pipeline.cancel() -# end pipeline cancel - # boards list boards = project.boards.list() # end boards list diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index 03959502d..b39c73b06 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -484,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 =============== @@ -634,60 +604,6 @@ Delete a project hook: :start-after: # hook delete :end-before: # end hook delete -Project pipelines -================= - -Reference ---------- - -* v4 API: - - + :class:`gitlab.v4.objects.ProjectPipeline` - + :class:`gitlab.v4.objects.ProjectPipelineManager` - + :attr:`gitlab.v4.objects.Project.pipelines` - -* v3 API: - - + :class:`gitlab.v3.objects.ProjectPipeline` - + :class:`gitlab.v3.objects.ProjectPipelineManager` - + :attr:`gitlab.v3.objects.Project.pipelines` - + :attr:`gitlab.Gitlab.project_pipelines` - -* GitLab API: https://docs.gitlab.com/ce/api/pipelines.html - -Examples --------- - -List pipelines for a project: - -.. literalinclude:: projects.py - :start-after: # pipeline list - :end-before: # end pipeline list - -Get a pipeline for a project: - -.. literalinclude:: projects.py - :start-after: # pipeline get - :end-before: # end pipeline get - -Retry the failed builds for a pipeline: - -.. literalinclude:: projects.py - :start-after: # pipeline retry - :end-before: # end pipeline retry - -Cancel builds in a pipeline: - -.. literalinclude:: projects.py - :start-after: # pipeline cancel - :end-before: # end pipeline cancel - -Create a pipeline for a particular reference: - -.. literalinclude:: projects.py - :start-after: # pipeline create - :end-before: # end pipeline create - Project Services ================ diff --git a/docs/install.rst b/docs/install.rst index 1bc6d1706..499832072 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 217463d9d..ef2106088 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,7 +30,7 @@ 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 diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 846380f5b..17e60bccf 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -34,11 +34,11 @@ from gitlab.v3.objects import * # noqa __title__ = 'python-gitlab' -__version__ = '1.2.0' +__version__ = '1.3.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' -__copyright__ = 'Copyright 2013-2017 Gauvain Pocentek' +__copyright__ = 'Copyright 2013-2018 Gauvain Pocentek' warnings.filterwarnings('default', category=DeprecationWarning, module='^gitlab') @@ -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,7 @@ def __init__(self, url, private_token=None, oauth_token=None, email=None, 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) @@ -146,6 +147,12 @@ def __init__(self, url, private_token=None, oauth_token=None, email=None, 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') diff --git a/gitlab/config.py b/gitlab/config.py index 9cf208c43..0f4c42439 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -128,7 +128,11 @@ def __init__(self, gitlab_id=None, config_files=None): 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 9a423dd4a..5825d2349 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/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py index d33df9952..1a1f3d83f 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 f7fd1872f..844ba9e83 100644 --- a/gitlab/tests/test_gitlabobject.py +++ b/gitlab/tests/test_gitlabobject.py @@ -34,7 +34,7 @@ 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 @@ def test_json(self): 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 @@ def setUp(self): 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 @@ def resp_diff(self, url, request): 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 @@ def resp_diff_fail(self, url, request): 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 @@ def resp_blob(self, url, request): 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 @@ def setUp(self): 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 @@ def resp_content(self, url, request): 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 @@ def setUp(self): 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 @@ def resp_content(self, url, request): 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 5cd3130d1..c6ef2992c 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/v4/objects.py b/gitlab/v4/objects.py index d7bb3d590..f10754028 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -112,6 +112,17 @@ def compound_metrics(self, **kwargs): 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'), @@ -1136,12 +1183,11 @@ def enable(self, key_id, **kwargs): 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'} @@ -1557,6 +1603,30 @@ def merge(self, merge_commit_message=None, **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' @@ -1882,7 +1952,21 @@ def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024, 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', 'ProjectPipelineJobManager'), ) + @cli.register_custom_action('ProjectPipeline') @exc.on_http_error(exc.GitlabPipelineCancelError) def cancel(self, **kwargs): @@ -1940,6 +2024,62 @@ def create(self, data, **kwargs): return CreateMixin.create(self, data, path=path, **kwargs) +class ProjectPipelineScheduleVariable(SaveMixin, ObjectDeleteMixin, + RESTObject): + _id_attr = 'key' + + +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 = (('key', 'value'), tuple()) + _update_attrs = (('key', 'value'), tuple()) + + +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. + + 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 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')) + + +class ProjectPipelineJob(ProjectJob): + pass + + +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 @@ -2024,8 +2164,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) @@ -2241,6 +2390,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('pagesdomains', 'ProjectPagesDomainManager'), ('pipelines', 'ProjectPipelineManager'), ('protectedbranches', 'ProjectProtectedBranchManager'), + ('pipelineschedules', 'ProjectPipelineScheduleManager'), ('runners', 'ProjectRunnerManager'), ('services', 'ProjectServiceManager'), ('snippets', 'ProjectSnippetManager'), @@ -2531,7 +2681,7 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): See https://gitlab.com/help/ci/triggers/README.md#trigger-a-build Args: - ref (str): Commit to build; can be a commit SHA, a branch name, ... + ref (str): Commit to build; can be a branch name or a tag token (str): The trigger token variables (dict): Variables passed to the build script **kwargs: Extra options to send to the server (e.g. sudo) @@ -2542,7 +2692,9 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): """ path = '/projects/%s/trigger/pipeline' % self.get_id() post_data = {'ref': ref, 'token': token, 'variables': variables} - self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) + attrs = self.manager.gitlab.http_post( + path, post_data=post_data, **kwargs) + return ProjectPipeline(self.pipelines, attrs) @cli.register_custom_action('Project') @exc.on_http_error(exc.GitlabHousekeepingError) diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index e06502018..695722f9c 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -92,6 +92,12 @@ 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'}) @@ -405,7 +411,7 @@ env.delete() assert(len(admin_project.environments.list()) == 0) -# events +# project events admin_project.events.list() # forks @@ -527,6 +533,12 @@ 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') @@ -631,3 +643,6 @@ # user activities gl.user_activities.list() + +# events +gl.events.list()