diff options
41 files changed, 903 insertions, 252 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..204be74 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +venv/ +dist/ +build/ +*.egg-info +.github/ diff --git a/.travis.yml b/.travis.yml index 10277f7..6b18f8b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,3 +21,12 @@ install: - pip install tox script: - tox -e $TOX_ENV + +deploy: + provider: pypi + user: max-wittig + password: + secure: LmNkZdbNe1oBSJ/PeTCKXaeu9Ml/biY4ZN4aedbD4lLXbxV/sgsHEE4N1Xrg2D/CJsnNjBY7CHzO0vL5iak8IRpV61xkdquZHvAUQKuhjMY30HopReAEw8sP+Wpf3lYcD1BjC5KT9vqWG99feoQ6epRt//Xm4DdkBYNmmUsCsMBTZLlGnj3B/mE8w+XQxQpdA2QzpRJ549N12vidwZRKqP0Zuug3rELVSo64O2bpqarKx/EeUUhTXZ0Y4XeVYgvuHBjvPqtuSJzR17CNkjaBhacD7EFTP34sAaCKGRDpfYiiiGx9LeKOEAv5Hj0+LOqEC/o6EyiIFviE+HvLQ/kBLJ6Oo2p47fibyIU/YOAFdZYKmBRq2ZUaV0DhhuuCRPZ+yLrsuaFRrKTVEMsHVtdsXJkW5gKG08vwOndW+kamppRhkAcdFVyokIgu/6nPBRWMuS6ue2aKoKRdP2gmqk0daKM1ao2uv06A2/J1/xkPy1EX5MjyK8Mh78ooKjITp5DHYn8l1pxaB0YcEkRzfwMyLErGQaRDgo7rCOm0tTRNhArkn0VE1/KLKFbATo2NSxZDwUJQ5TBNCEqfdBN1VzNEduJ7ajbZpq3DsBRM/9hzQ5LLxn7azMl9m+WmT12Qcgz25wg2Sgbs9Z2rT6fto5h8GSLpy8ReHo+S6fALJBzA4pg= + distributions: sdist bdist_wheel + on: + tags: true @@ -1,100 +1,10 @@ -Authors -------- +Authors / Maintainers +--------------------- -Gauvain Pocentek <gauvain@pocentek.net> -Mika Mäenpää <mika.j.maenpaa@tut.fi> +Gauvain Pocentek <gauvainpocentek@gmail.com> +Max Wittig <max.wittig@siemens.com> Contributors ------------ -Adam Reid <areid@navtech.aero> -Alexander Skiba <mail@ghostlyrics.net> -Alex Widener <alexwidener@users.noreply.github.com> -Amar Sood (tekacs) <mail@tekacs.com> -Andjelko Horvat <ahorvat@reflected.net> -Andreas Nüßlein <nutz@noova.de> -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> -Cosimo Lupo <cosimo.lupo@daltonmaag.com> -Crestez Dan Leonard <lcrestez@ixiacom.com> -Cyril Jouve <jv.cyril@gmail.com> -Daniel Kimsey <dekimsey@ufl.edu> -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> -Eric Sabouraud <esabouraud@users.noreply.github.com> -Erik Weatherwax <erik.weatherwax@xls.xerox.com> -fgouteroux <francois.gouteroux@d2-si.eu> -Greg Allen <GregoryEAllen@users.noreply.github.com> -Guillaume Delacour <gui@iroqwa.org> -Guyzmo <guyzmo+github+pub@m0g.net> -hakkeroid <hakkeroid@users.noreply.github.com> -Ian Sparks <isparks@mdsol.com> -itxaka <itxakaserrano@gmail.com> -Ivica Arsov <ivica.arsov@sculpteo.com> -Jakub Wilk <jwilk@jwilk.net> -James (d0c_s4vage) Johnson <james.johnson@exodusintel.com> -James E. Flemer <james.flemer@ndpgroup.com> -James Johnson <d0c.s4vage@gmail.com> -Jamie Bliss <astronouth7303@gmail.com> -Jason Antman <jason@jasonantman.com> -Jerome Robert <jeromerobert@gmx.com> -Johan Brandhorst <johan@cognitivelogic.com> -Jonathon Reinhart <Jonathon.Reinhart@gmail.com> -Jon Banafato <jon@jonafato.com> -Keith Wansbrough <keithw@lochan.org> -Koen Smets <koen.smets@gmail.com> -Kris Gambirazzi <Kris@sitehost.co.nz> -leon <yuanliangliu@gmail.com> -Lyudmil Nenov <lyudmil.nenov@gmail.com> -Mart Sõmermaa <mart.somermaa@cgi.com> -massimone88 <stefano.mandruzzato@gmail.com> -Matej Zerovnik <matej@zunaj.si> -Matt Odden <locke105@gmail.com> -Matus Ferech <matus.ferech@telekom.com> -Maura Hausman <mhausman@wayfair.com> -Maxime Guyot <maxime.guyot@elits.com> -Max Wittig <max.wittig@siemens.com> -Michael Overmeyer <m.overmeyer@yahoo.ca> -Michal Galet <michal.galet@gmail.com> -Mike Kobit <mkobit@gmail.com> -Mikhail Lopotkov <ms.lopotkov@tensor.ru> -Miouge1 <Miouge1@users.noreply.github.com> -Missionrulz <missionrulz@gmail.com> -Mond WAN <mondwan@users.noreply.github.com> -Moritz Lipp <github@mlq.me> -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> -Pierre Tardy <tardyp@gmail.com> -Rafael Eyng <rafaeleyng@gmail.com> -Richard Hansen <rhansen@rhansen.org> -Robert Lu <robberphex@gmail.com> -samcday <sam.c.day@gmail.com> -savenger <github@smahmood.de> -Stefan Crain <stefancrain@users.noreply.github.com> -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> -Twan <tmeynen@inuits.eu> -Will Starms <vilhelmen@gmail.com> -Yosi Zelensky <yosyos04@gmail.com> + +See ``git log`` for a full list of contributors. diff --git a/ChangeLog.rst b/ChangeLog.rst index 5b2c497..3e96318 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,6 +1,48 @@ ChangeLog ========= +Version 1.7.0_ - 2018-12-09 +--------------------------- + +* [docs] Fix the owned/starred usage documentation +* [docs] Add a warning about http to https redirects +* Fix the https redirection test +* [docs] Add a note about GroupProject limited API +* Add missing comma in ProjectIssueManager _create_attrs +* More flexible docker image +* Add project protected tags management +* [cli] Print help and usage without config file +* Rename MASTER_ACCESS to MAINTAINER_ACCESS +* [docs] Add docs build information +* Use docker image with current sources +* [docs] Add PyYAML requirement notice +* Add Gitter badge to README +* [docs] Add an example of pipeline schedule vars listing +* [cli] Exit on config parse error, instead of crashing +* Add support for resource label events +* [docs] Fix the milestone filetring doc (iid -> iids) +* [docs] Fix typo in custom attributes example +* Improve error message handling in exceptions +* Add support for members all() method +* Add access control options to protected branch creation + +Version 1.6.0_ - 2018-08-25 +--------------------------- + +* [docs] Don't use hardcoded values for ids +* [docs] Improve the snippets examples +* [cli] Output: handle bytes in API responses +* [cli] Fix the case where we have nothing to print +* Project import: fix the override_params parameter +* Support group and global MR listing +* Implement MR.pipelines() +* MR: add the squash attribute for create/update +* Added support for listing forks of a project +* [docs] Add/update notes about read-only objects +* Raise an exception on https redirects for PUT/POST +* [docs] Add a FAQ +* [cli] Fix the project-export download + Version 1.5.1_ - 2018-06-23 --------------------------- @@ -643,7 +685,9 @@ Version 0.1 - 2013-07-08 * Initial release -.. _1.5.1: https://github.com/python-gitlab/python-gitlab/compare/1.4.0...1.5.1 +.. _1.7.0: https://github.com/python-gitlab/python-gitlab/compare/1.6.0...1.7.0 +.. _1.6.0: https://github.com/python-gitlab/python-gitlab/compare/1.5.1...1.6.0 +.. _1.5.1: https://github.com/python-gitlab/python-gitlab/compare/1.5.0...1.5.1 .. _1.5.0: https://github.com/python-gitlab/python-gitlab/compare/1.4.0...1.5.0 .. _1.4.0: https://github.com/python-gitlab/python-gitlab/compare/1.3.0...1.4.0 .. _1.3.0: https://github.com/python-gitlab/python-gitlab/compare/1.2.0...1.3.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..489a420 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.7-alpine AS build + +WORKDIR /opt/python-gitlab +COPY . . +RUN python setup.py bdist_wheel + +FROM python:3.7-alpine + +WORKDIR /opt/python-gitlab +COPY --from=build /opt/python-gitlab/dist dist/ +RUN pip install PyYaml +RUN pip install $(find dist -name *.whl) && \ + rm -rf dist/ +COPY docker-entrypoint.sh /usr/local/bin/ + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["--version"] @@ -10,6 +10,9 @@ .. image:: https://img.shields.io/pypi/pyversions/python-gitlab.svg :target: https://pypi.python.org/pypi/python-gitlab +.. image:: https://img.shields.io/gitter/room/python-gitlab/Lobby.svg + :target: https://gitter.im/python-gitlab/Lobby + Python GitLab ============= @@ -35,6 +38,27 @@ Install with pip pip install python-gitlab + +Using the python-gitlab docker image +==================================== + +How to build +------------ + +``docker build -t python-gitlab:TAG .`` + +How to use +---------- + +``docker run -it --rm -e GITLAB_PRIVATE_TOKEN=<your token> -v /path/to/python-gitlab.cfg:/python-gitlab.cfg python-gitlab <command> ...`` + +To change the GitLab URL, use `-e GITLAB_URL=<your url>` + + +Bring your own config file: +``docker run -it --rm -v /path/to/python-gitlab.cfg:/python-gitlab.cfg -e GITLAB_CFG=/python-gitlab.cfg python-gitlab <command> ...`` + + Bug reports =========== @@ -48,6 +72,13 @@ Documentation The full documentation for CLI and API is available on `readthedocs <http://python-gitlab.readthedocs.org/en/stable/>`_. +Build the docs +-------------- +You can build the documentation using ``sphinx``:: + + pip install sphinx + python setup.py build_sphinx + Contributing ============ @@ -60,6 +91,9 @@ You can contribute to the project in multiple ways: * Add unit and functional tests * Everything else you can think of +We prefer commit messages to be formatted using the `conventional-changelog <https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines>`_. +This leads to more readable messages that are easy to follow when looking through the project history. + Provide your patches as github pull requests. Thanks! Running unit tests diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index 9e9fd8c..6abb980 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -4,6 +4,34 @@ Release notes This page describes important changes between python-gitlab releases. +Changes from 1.7 to 1.8 +======================= + +* You can now use the ``query_parameters`` argument in method calls to define + arguments to send to the GitLab server. This allows to avoid conflicts + between python-gitlab and GitLab server variables, and allows to use the + python reserved keywords as GitLab arguments. + + The following examples make the same GitLab request with the 2 syntaxes:: + + projects = gl.projects.list(owned=True, starred=True) + projects = gl.projects.list(query_parameters={'owned': True, 'starred': True}) + + The following example only works with the new parameter:: + + activities = gl.user_activities.list( + query_parameters={'from': '2019-01-01'}, + all=True) + +Changes from 1.5 to 1.6 +======================= + +* When python-gitlab detects HTTP redirections from http to https it will raise + a RedirectionError instead of a cryptic error. + + Make sure to use an ``https://`` protocol in your GitLab URL parameter if the + server requires it. + Changes from 1.4 to 1.5 ======================= @@ -14,6 +42,7 @@ Changes from 1.4 to 1.5 configuration, epics. * The ``GetFromListMixin`` class has been removed. The ``get()`` method is not available anymore for the following managers: + - UserKeyManager - DeployKeyManager - GroupAccessRequestManager @@ -27,6 +56,7 @@ Changes from 1.4 to 1.5 - ProjectPipelineJobManager - ProjectAccessRequestManager - TodoManager + * ``ProjectPipelineJob`` do not heritate from ``ProjectJob`` anymore and thus can only be listed. diff --git a/contrib/docker/Dockerfile b/contrib/docker/Dockerfile deleted file mode 100644 index 6663cac..0000000 --- a/contrib/docker/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:slim - -# Install python-gitlab -RUN pip install --upgrade python-gitlab - -# Copy sample configuration file -COPY python-gitlab.cfg / - -# Define the entrypoint that enable a configuration file -ENTRYPOINT ["gitlab", "--config-file", "/python-gitlab.cfg"] diff --git a/contrib/docker/README.rst b/contrib/docker/README.rst deleted file mode 100644 index 90a576c..0000000 --- a/contrib/docker/README.rst +++ /dev/null @@ -1,19 +0,0 @@ -python-gitlab docker image -========================== - -Dockerfile contributed by *oupala*: -https://github.com/python-gitlab/python-gitlab/issues/295 - -How to build ------------- - -``docker build -t me/python-gitlab:VERSION .`` - -How to use ----------- - -``docker run -it -v /path/to/python-gitlab.cfg:/python-gitlab.cfg python-gitlab <command> ...`` - -To make things easier you can create a shell alias: - -``alias gitlab='docker run --rm -it -v /path/to/python-gitlab.cfg:/python-gitlab.cfg python-gitlab`` diff --git a/contrib/docker/python-gitlab.cfg b/contrib/docker/python-gitlab.cfg deleted file mode 100644 index 0e51954..0000000 --- a/contrib/docker/python-gitlab.cfg +++ /dev/null @@ -1,15 +0,0 @@ -[global] -default = somewhere -ssl_verify = true -timeout = 5 -api_version = 3 - -[somewhere] -url = https://some.whe.re -private_token = vTbFeqJYCY3sibBP7BZM -api_version = 4 - -[elsewhere] -url = http://else.whe.re:8080 -private_token = CkqsjqcQSFH5FQKDccu4 -timeout = 1 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..bda8141 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +GITLAB_CFG=${GITLAB_CFG:-"/etc/python-gitlab-default.cfg"} + +cat << EOF > /etc/python-gitlab-default.cfg +[global] +default = gitlab +ssl_verify = ${GITLAB_SSL_VERIFY:-true} +timeout = ${GITLAB_TIMEOUT:-5} +api_version = ${GITLAB_API_VERSION:-4} +per_page = ${GITLAB_PER_PAGE:-10} + +[gitlab] +url = ${GITLAB_URL:-https://gitlab.com} +private_token = ${GITLAB_PRIVATE_TOKEN} +oauth_token = ${GITLAB_OAUTH_TOKEN} +http_username = ${GITLAB_HTTP_USERNAME} +http_password = ${GITLAB_HTTP_PASSWORD} +EOF + +exec gitlab --config-file "${GITLAB_CFG}" "$@" diff --git a/docs/api-usage.rst b/docs/api-usage.rst index ede2d47..8ab252c 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -2,12 +2,12 @@ Getting started with the API ############################ -python-gitlab supports both GitLab v3 and v4 APIs. To use the v3 make sure to +python-gitlab supports both GitLab v3 and v4 APIs. .. note:: To use the v3 make sure to install python-gitlab 1.4. Only the v4 API is - documented here. See the documentation of earlier version for the v3 API. + documented here. See the documentation of earlier versions for the v3 API. ``gitlab.Gitlab`` class ======================= @@ -43,6 +43,11 @@ You can also use configuration files to create ``gitlab.Gitlab`` objects: See the :ref:`cli_configuration` section for more information about configuration files. +.. warning:: + + If the GitLab server you are using redirects requests from http to https, + make sure to use the ``https://`` protocol in the URL definition. + Note on password authentication ------------------------------- @@ -88,7 +93,7 @@ Examples: You can list the mandatory and optional attributes for object creation and update with the manager's ``get_create_attrs()`` and ``get_update_attrs()`` methods. They return 2 tuples, the first one is the list of mandatory -attributes, the second one the list of optional attribute: +attributes, the second one is the list of optional attribute: .. code-block:: python @@ -113,6 +118,25 @@ Some objects also provide managers to access related GitLab resources: project = gl.projects.get(1) issues = project.issues.list() +python-gitlab allows to send any data to the GitLab server when making queries. +In case of invalid or missing arguments python-gitlab will raise an exception +with the GitLab server error message: + +.. code-block:: python + + >>> gl.projects.list(sort='invalid value') + ... + GitlabListError: 400: sort does not have a valid value + +You can use the ``query_parameters`` argument to send arguments that would +conflict with python or python-gitlab when using them as kwargs: + +.. code-block:: python + + gl.user_activities.list(from='2019-01-01') ## invalid + + gl.user_activities.list(query_parameters={'from': '2019-01-01'}) # OK + Gitlab Objects ============== @@ -187,7 +211,7 @@ parameter to get all the items when using listing methods: .. code-block:: python all_groups = gl.groups.list(all=True) - all_owned_projects = gl.projects.owned(all=True) + all_owned_projects = gl.projects.list(owned=True, all=True) You can define the ``per_page`` value globally to avoid passing it to every ``list()`` method call: @@ -206,7 +230,7 @@ through a large number of items: for item in items: print(item.attributes) -The generator exposes extra listing information as received by the server: +The generator exposes extra listing information as received from the server: * ``current_page``: current page number (first page is 1) * ``prev_page``: if ``None`` the current page is the first one @@ -249,7 +273,7 @@ properly closed when you exit a ``with`` block: .. warning:: The context manager will also close the custom ``Session`` object you might - have used to build a ``Gitlab`` instance. + have used to build the ``Gitlab`` instance. Proxy configuration ------------------- @@ -294,7 +318,9 @@ Rate limits python-gitlab obeys the rate limit of the GitLab server by default. On receiving a 429 response (Too Many Requests), python-gitlab sleeps for the -amount of time in the Retry-After header that GitLab sends back. +amount of time in the Retry-After header that GitLab sends back. If GitLab +does not return a response with the Retry-After header, python-gitlab will +perform an exponential backoff. If you don't want to wait, you can disable the rate-limiting feature, by supplying the ``obey_rate_limit`` argument. @@ -307,6 +333,18 @@ supplying the ``obey_rate_limit`` argument. gl = gitlab.gitlab(url, token, api_version=4) gl.projects.list(all=True, obey_rate_limit=False) +If you do not disable the rate-limiting feature, you can supply a custom value +for ``max_retries``; by default, this is set to 10. To retry without bound when +throttled, you can set this parameter to -1. This parameter is ignored if +``obey_rate_limit`` is set to ``False``. + +.. code-block:: python + + import gitlab + import requests + + gl = gitlab.gitlab(url, token, api_version=4) + gl.projects.list(all=True, max_retries=12) .. warning:: diff --git a/docs/cli.rst b/docs/cli.rst index 654c00a..2051d03 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -69,7 +69,7 @@ parameters. You can override the values in each GitLab server section. - Integer - Number of seconds to wait for an answer before failing. * - ``api_version`` - - ``3`` ou ``4`` + - ``3`` or ``4`` - The API version to use to make queries. Requires python-gitlab >= 1.3.0. * - ``per_page`` - Integer between 1 and 100 @@ -78,6 +78,11 @@ parameters. You can override the values in each GitLab server section. You must define the ``url`` in each GitLab server section. +.. warning:: + + If the GitLab server you are using redirects requests from http to https, + make sure to use the ``https://`` protocol in the ``url`` definition. + Only one of ``private_token`` or ``oauth_token`` should be defined. If neither are defined an anonymous request will be sent to the Gitlab server, with very limited permissions. @@ -152,6 +157,11 @@ These options must be defined before the mandatory arguments. ``--output``, ``-o`` Output format. Defaults to a custom format. Can also be ``yaml`` or ``json``. + **Notice:** + + The `PyYAML package <https://pypi.org/project/PyYAML/>`_ is required to use the yaml output option. + You need to install it separately using ``pip install PyYAML`` + ``--fields``, ``-f`` Comma-separated list of fields to display (``yaml`` and ``json`` output formats only). If not used, all the object fields are displayed. diff --git a/docs/faq.rst b/docs/faq.rst new file mode 100644 index 0000000..fe71198 --- /dev/null +++ b/docs/faq.rst @@ -0,0 +1,33 @@ +### +FAQ +### + +I cannot edit the merge request / issue I've just retrieved + It is likely that you used a ``MergeRequest``, ``GroupMergeRequest``, + ``Issue`` or ``GroupIssue`` object. These objects cannot be edited. But you + can create a new ``ProjectMergeRequest`` or ``ProjectIssue`` object to + apply changes. For example:: + + issue = gl.issues.list()[0] + project = gl.projects.get(issue.project_id, lazy=True) + editable_issue = project.issues.get(issue.iid, lazy=True) + # you can now edit the object + + See the :ref:`merge requests example <merge_requests_examples>` and the + :ref:`issues examples <issues_examples>`. + +How can I clone the repository of a project? + python-gitlab doesn't provide an API to clone a project. You have to use a + git library or call the ``git`` command. + + The git URI is exposed in the ``ssh_url_to_repo`` attribute of ``Project`` + objects. + + Example:: + + import subprocess + + project = gl.projects.create(data) # or gl.projects.get(project_id) + print(project.attributes) # displays all the attributes + git_url = project.ssh_url_to_repo + subprocess.call(['git', 'clone', git_url]) diff --git a/docs/gl_objects/access_requests.rst b/docs/gl_objects/access_requests.rst index 9a147c1..e890ce0 100644 --- a/docs/gl_objects/access_requests.rst +++ b/docs/gl_objects/access_requests.rst @@ -10,7 +10,7 @@ following constants are provided to represent the access levels: * ``gitlab.GUEST_ACCESS``: ``10`` * ``gitlab.REPORTER_ACCESS``: ``20`` * ``gitlab.DEVELOPER_ACCESS``: ``30`` -* ``gitlab.MASTER_ACCESS``: ``40`` +* ``gitlab.MAINTAINER_ACCESS``: ``40`` * ``gitlab.OWNER_ACCESS``: ``50`` References @@ -43,7 +43,7 @@ Create an access request:: Approve an access request:: ar.approve() # defaults to DEVELOPER level - ar.approve(access_level=gitlab.MASTER_ACCESS) # explicitly set access level + ar.approve(access_level=gitlab.MAINTAINER_ACCESS) # explicitly set access level Deny (delete) an access request:: diff --git a/docs/gl_objects/builds.rst b/docs/gl_objects/builds.rst index 51e7496..ee45090 100644 --- a/docs/gl_objects/builds.rst +++ b/docs/gl_objects/builds.rst @@ -141,6 +141,13 @@ Delete a schedule:: sched.delete() +List schedule variables:: + + # note: you need to use get() to retrieve the schedule variables. The + # attribute is not present in the response of a list() call + sched = projects.pipelineschedules.get(schedule_id) + vars = sched.attributes['variables'] + Create a schedule variable:: var = sched.variables.create({'key': 'foo', 'value': 'bar'}) diff --git a/docs/gl_objects/commits.rst b/docs/gl_objects/commits.rst index f662fcb..9f48c98 100644 --- a/docs/gl_objects/commits.rst +++ b/docs/gl_objects/commits.rst @@ -27,6 +27,14 @@ results:: commits = project.commits.list(ref_name='my_branch') commits = project.commits.list(since='2016-01-01T00:00:00Z') +.. note:: + + The available ``all`` listing argument conflicts with the python-gitlab + argument. Use ``query_parameters`` to avoid the conflict:: + + commits = project.commits.list(all=True, + query_parameters={'ref_name': 'my_branch'}) + Create a commit:: # See https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions @@ -85,7 +93,7 @@ Reference + :class:`gitlab.v4.objects.ProjectCommitComment` + :class:`gitlab.v4.objects.ProjectCommitCommentManager` - + :attr:`gitlab.v4.objects.Commit.comments` + + :attr:`gitlab.v4.objects.ProjectCommit.comments` * GitLab API: https://docs.gitlab.com/ce/api/commits.html @@ -116,7 +124,7 @@ Reference + :class:`gitlab.v4.objects.ProjectCommitStatus` + :class:`gitlab.v4.objects.ProjectCommitStatusManager` - + :attr:`gitlab.v4.objects.Commit.statuses` + + :attr:`gitlab.v4.objects.ProjectCommit.statuses` * GitLab API: https://docs.gitlab.com/ce/api/commits.html diff --git a/docs/gl_objects/discussions.rst b/docs/gl_objects/discussions.rst index 7673b7c..444d883 100644 --- a/docs/gl_objects/discussions.rst +++ b/docs/gl_objects/discussions.rst @@ -48,7 +48,7 @@ List the discussions for a resource (issue, merge request, snippet or commit):: Get a single discussion:: - discussion = resource.discussion.get(discussion_id) + discussion = resource.discussions.get(discussion_id) You can access the individual notes in the discussion through the ``notes`` attribute. It holds a list of notes in chronological order:: @@ -68,7 +68,7 @@ You can add notes to existing discussions:: You can get and update a single note using the ``*DiscussionNote`` resources:: - discussion = resource.discussion.get(discussion_id) + discussion = resource.discussions.get(discussion_id) # Get the latest note's id note_id = discussion.attributes['note'][-1]['id'] last_note = discussion.notes.get(note_id) @@ -77,7 +77,7 @@ You can get and update a single note using the ``*DiscussionNote`` resources:: Create a new discussion:: - discussion = resource.discussion.create({'body': 'First comment of discussion'}) + discussion = resource.discussions.create({'body': 'First comment of discussion'}) You can comment on merge requests and commit diffs. Provide the ``position`` dict to define where the comment should appear in the diff:: diff --git a/docs/gl_objects/groups.rst b/docs/gl_objects/groups.rst index 5ef5469..7fcf980 100644 --- a/docs/gl_objects/groups.rst +++ b/docs/gl_objects/groups.rst @@ -31,6 +31,15 @@ List a group's projects:: projects = group.projects.list() +.. note:: + + ``GroupProject`` objects returned by this API call are very limited, and do + not provide all the features of ``Project`` objects. If you need to + manipulate projects, create a new ``Project`` object:: + + first_group_project = group.projects.list()[0] + manageable_project = gl.projects.get(first_group_project.id, lazy=True) + You can filter and sort the result using the following parameters: * ``archived``: limit by archived status @@ -53,7 +62,7 @@ Update a group:: Remove a group:: - gl.group.delete(group_id) + gl.groups.delete(group_id) # or group.delete() @@ -76,11 +85,14 @@ 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() +.. note:: + + 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 ======================= @@ -130,7 +142,7 @@ The following constants define the supported access levels: * ``gitlab.GUEST_ACCESS = 10`` * ``gitlab.REPORTER_ACCESS = 20`` * ``gitlab.DEVELOPER_ACCESS = 30`` -* ``gitlab.MASTER_ACCESS = 40`` +* ``gitlab.MAINTAINER_ACCESS = 40`` * ``gitlab.OWNER_ACCESS = 50`` Reference @@ -152,6 +164,11 @@ List group members:: members = group.members.list() +List the group members recursively (including inherited members through +ancestor groups):: + + members = group.members.all(all=True) + Get a group member:: members = group.members.get(member_id) diff --git a/docs/gl_objects/issues.rst b/docs/gl_objects/issues.rst index 7abaa78..12df90b 100644 --- a/docs/gl_objects/issues.rst +++ b/docs/gl_objects/issues.rst @@ -1,3 +1,5 @@ +.. _issues_examples: + ###### Issues ###### @@ -30,6 +32,17 @@ Use the ``state`` and ``label`` parameters to filter the results. Use the closed_issues = gl.issues.list(state='closed') tagged_issues = gl.issues.list(labels=['foo', 'bar']) +.. note:: + + It is not possible to edit or delete Issue objects. You need to create a + ProjectIssue object to perform changes:: + + issue = gl.issues.list()[0] + project = gl.projects.get(issue.project_id, lazy=True) + editable_issue = project.issues.get(issue.iid, lazy=True) + editable_issue.title = updated_title + editable_issue.save() + Group issues ============ @@ -55,6 +68,17 @@ List the group issues:: # Order using the order_by and sort parameters issues = group.issues.list(order_by='created_at', sort='desc') +.. note:: + + It is not possible to edit or delete GroupIssue objects. You need to create + a ProjectIssue object to perform changes:: + + issue = group.issues.list()[0] + project = gl.projects.get(issue.project_id, lazy=True) + editable_issue = project.issues.get(issue.iid, lazy=True) + editable_issue.title = updated_title + editable_issue.save() + Project issues ============== diff --git a/docs/gl_objects/labels.rst b/docs/gl_objects/labels.rst index 1c98971..a4667aa 100644 --- a/docs/gl_objects/labels.rst +++ b/docs/gl_objects/labels.rst @@ -2,6 +2,9 @@ Labels ###### +Project labels +============== + Reference --------- @@ -48,3 +51,39 @@ Manage labels in issues and merge requests:: 'labels': ['foo']}) issue.labels.append('bar') issue.save() + +Label events +============ + +Resource label events keep track about who, when, and which label was added or +removed to an issuable. + +Group epic label events are only available in the EE edition. + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectIssueResourceLabelEvent` + + :class:`gitlab.v4.objects.ProjectIssueResourceLabelEventManager` + + :attr:`gitlab.v4.objects.ProjectIssue.resourcelabelevents` + + :class:`gitlab.v4.objects.ProjectMergeRequestResourceLabelEvent` + + :class:`gitlab.v4.objects.ProjectMergeRequestResourceLabelEventManager` + + :attr:`gitlab.v4.objects.ProjectMergeRequest.resourcelabelevents` + + :class:`gitlab.v4.objects.GroupEpicResourceLabelEvent` + + :class:`gitlab.v4.objects.GroupEpicResourceLabelEventManager` + + :attr:`gitlab.v4.objects.GroupEpic.resourcelabelevents` + +* GitLab API: https://docs.gitlab.com/ee/api/resource_label_events.html + +Examples +-------- + +Get the events for a resource (issue, merge request or epic):: + + events = resource.resourcelabelevents.list() + +Get a specific event for a resource:: + + event = resource.resourcelabelevents.get(event_id) diff --git a/docs/gl_objects/milestones.rst b/docs/gl_objects/milestones.rst index 0d3f576..f24e13f 100644 --- a/docs/gl_objects/milestones.rst +++ b/docs/gl_objects/milestones.rst @@ -30,7 +30,7 @@ List the milestones for a project or a group:: You can filter the list using the following parameters: -* ``iid``: unique ID of the milestone for the project +* ``iids``: unique IDs of milestones for the project * ``state``: either ``active`` or ``closed`` * ``search``: to search using a string diff --git a/docs/gl_objects/mrs.rst b/docs/gl_objects/mrs.rst index ca9b864..b3b5e07 100644 --- a/docs/gl_objects/mrs.rst +++ b/docs/gl_objects/mrs.rst @@ -1,3 +1,5 @@ +.. _merge_requests_examples: + ############## Merge requests ############## @@ -5,6 +7,53 @@ Merge requests You can use merge requests to notify a project that a branch is ready for merging. The owner of the target projet can accept the merge request. +Merge requests are linked to projects, but they can be listed globally or for +groups. + +Group and global listing +======================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.GroupMergeRequest` + + :class:`gitlab.v4.objects.GroupMergeRequestManager` + + :attr:`gitlab.v4.objects.Group.mergerequests` + + :class:`gitlab.v4.objects.MergeRequest` + + :class:`gitlab.v4.objects.MergeRequestManager` + + :attr:`gitlab.Gtilab.mergerequests` + +* GitLab API: https://docs.gitlab.com/ce/api/merge_requests.html + +Examples +-------- + +List the merge requests available on the GitLab server:: + + mrs = gl.mergerequests.list() + +List the merge requests for a group:: + + group = gl.groups.get('mygroup') + mrs = group.mergerequests.list() + +.. note:: + + It is not possible to edit or delete ``MergeRequest`` and + ``GroupMergeRequest`` objects. You need to create a ``ProjectMergeRequest`` + object to apply changes:: + + mr = group.mergerequests.list()[0] + project = gl.projects.get(mr.project_id, lazy=True) + editable_mr = project.mergerequests.get(mr.iid, lazy=True) + editable_mr.title = updated_title + editable_mr.save() + +Project merge requests +====================== + Reference --------- @@ -74,6 +123,14 @@ List commits of a MR:: commits = mr.commits() +List the changes of a MR:: + + changes = mr.changes() + +List the pipelines for a MR:: + + pipelines = mr.pipelines() + List issues that will close on merge:: mr.closes_issues() diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index 3950862..a00aae0 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -45,10 +45,10 @@ Results can also be sorted using the following parameters: projects = gl.projects.list(visibility='public') # List owned projects - projects = gl.projects.owned() + projects = gl.projects.list(owned=True) # List starred projects - projects = gl.projects.starred() + projects = gl.projects.list(starred=True) # Search projects projects = gl.projects.list(search='keyword') @@ -95,6 +95,10 @@ Fork a project:: # fork to a specific namespace fork = project.forks.create({'namespace': 'myteam'}) +Get a list of forks for the project:: + + forks = project.forks.list() + Create/delete a fork relation between projects (requires admin permissions):: project.create_fork_relation(source_project.id) @@ -243,7 +247,7 @@ generated by GitLab you need to: Import the project:: - gl.projects.import_project(open('/tmp/export.tgz', 'rb'), 'my_new_project') + ouput = gl.projects.import_project(open('/tmp/export.tgz', 'rb'), 'my_new_project') # Get a ProjectImport object to track the import status project_import = gl.projects.get(output['id'], lazy=True).imports.get() while project_import.import_status != 'finished': @@ -288,7 +292,7 @@ Delete a custom attribute for a project:: Search projects by custom attribute:: - project.customattributes.set('type': 'internal') + project.customattributes.set('type', 'internal') gl.projects.list(custom_attributes={'type': 'internal'}) Project files @@ -474,6 +478,11 @@ List the project members:: members = project.members.list() +List the project members recursively (including inherited members through +ancestor groups):: + + members = project.members.all(all=True) + Search project members matching a query string:: members = project.members.list(query='bar') @@ -489,7 +498,7 @@ Add a project member:: Modify a project member (change the access level):: - member.access_level = gitlab.MASTER_ACCESS + member.access_level = gitlab.MAINTAINER_ACCESS member.save() Remove a member from the project team:: @@ -653,3 +662,36 @@ Edit project push rules:: Delete project push rules:: pr.delete() + +Project protected tags +====================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectProtectedTag` + + :class:`gitlab.v4.objects.ProjectProtectedTagManager` + + :attr:`gitlab.v4.objects.Project.protectedtags` + +* GitLab API: https://docs.gitlab.com/ce/api/protected_tags.html + +Examples +--------- + +Get a list of protected tags from a project:: + + protected_tags = project.protectedtags.list() + +Get a single protected tag or wildcard protected tag:: + + protected_tag = project.protectedtags.get('v*') + +Protect a single repository tag or several project repository tags using a wildcard protected tag:: + + project.protectedtags.create({'name': 'v*', 'create_access_level': '40'}) + +Unprotect the given protected tag or wildcard protected tag.:: + + protected_tag.delete() diff --git a/docs/gl_objects/protected_branches.rst b/docs/gl_objects/protected_branches.rst index bd2b22b..3498aa5 100644 --- a/docs/gl_objects/protected_branches.rst +++ b/docs/gl_objects/protected_branches.rst @@ -32,7 +32,16 @@ Create a protected branch:: p_branch = project.protectedbranches.create({ 'name': '*-stable', 'merge_access_level': gitlab.DEVELOPER_ACCESS, - 'push_access_level': gitlab.MASTER_ACCESS + 'push_access_level': gitlab.MAINTAINER_ACCESS + }) + +Create a protected branch with more granular access control:: + + p_branch = project.protectedbranches.create({ + 'name': '*-stable', + 'allowed_to_push': [{"user_id": 99}, {"user_id": 98}], + 'allowed_to_merge': [{"group_id": 653}], + 'allowed_to_unprotect': [{"access_level": gitlab.MAINTAINER_ACCESS}] }) Delete a protected branch:: diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst index 3b9c040..e66ef3a 100644 --- a/docs/gl_objects/users.rst +++ b/docs/gl_objects/users.rst @@ -112,7 +112,7 @@ Delete a custom attribute for a user:: Search users by custom attribute:: - user.customattributes.set('role': 'QA') + user.customattributes.set('role', 'QA') gl.users.list(custom_attributes={'role': 'QA'}) User impersonation tokens @@ -190,7 +190,7 @@ are admin. * GitLab API: https://docs.gitlab.com/ce/api/users.html#list-all-gpg-keys -Exemples +Examples -------- List GPG keys for a user:: @@ -232,7 +232,7 @@ are admin. * GitLab API: https://docs.gitlab.com/ce/api/users.html#list-ssh-keys -Exemples +Examples -------- List SSH keys for a user:: @@ -270,7 +270,7 @@ are admin. * GitLab API: https://docs.gitlab.com/ce/api/users.html#list-emails -Exemples +Examples -------- List emails for a user:: @@ -312,4 +312,6 @@ Examples Get the users activities:: - activities = gl.user_activities.list(all=True, as_list=False) + activities = gl.user_activities.list( + query_parameters={'from': '2018-07-01'}, + all=True, as_list=False) diff --git a/docs/index.rst b/docs/index.rst index 7805fcf..9c8cfd3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: install cli api-usage + faq switching-to-v4 api-objects api/gitlab diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst index ef21060..e6490e3 100644 --- a/docs/switching-to-v4.rst +++ b/docs/switching-to-v4.rst @@ -10,7 +10,7 @@ solve some problems with the existing one. GitLab will stop supporting the v3 API soon, and you should consider switching to v4 if you use a recent version of GitLab (>= 9.0), or if you use -http://gitlab.com. +https://gitlab.com. Using the v4 API diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 1c13093..0e6e52f 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -28,9 +28,10 @@ import six import gitlab.config from gitlab.const import * # noqa from gitlab.exceptions import * # noqa +from gitlab import utils # noqa __title__ = 'python-gitlab' -__version__ = '1.5.1' +__version__ = '1.7.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' @@ -39,6 +40,9 @@ __copyright__ = 'Copyright 2013-2018 Gauvain Pocentek' warnings.filterwarnings('default', category=DeprecationWarning, module='^gitlab') +REDIRECT_MSG = ('python-gitlab detected an http to https redirection. You ' + 'must update your GitLab URL to use https:// to avoid issues.') + def _sanitize(value): if isinstance(value, dict): @@ -114,6 +118,7 @@ class Gitlab(object): self.ldapgroups = objects.LDAPGroupManager(self) self.licenses = objects.LicenseManager(self) self.namespaces = objects.NamespaceManager(self) + self.mergerequests = objects.MergeRequestManager(self) self.notificationsettings = objects.NotificationSettingsManager(self) self.projects = objects.ProjectManager(self) self.runners = objects.RunnerManager(self) @@ -393,6 +398,26 @@ class Gitlab(object): else: return '%s%s' % (self._url, path) + def _check_redirects(self, result): + # Check the requests history to detect http to https redirections. + # If the initial verb is POST, the next request will use a GET request, + # leading to an unwanted behaviour. + # If the initial verb is PUT, the data will not be send with the next + # request. + # If we detect a redirection to https with a POST or a PUT request, we + # raise an exception with a useful error message. + if result.history and self._base_url.startswith('http:'): + for item in result.history: + if item.status_code not in (301, 302): + continue + # GET methods can be redirected without issue + if item.request.method == 'GET': + continue + # Did we end-up with an https:// URL? + location = item.headers.get('Location', None) + if location and location.startswith('https://'): + raise RedirectError(REDIRECT_MSG) + def http_request(self, verb, path, query_data={}, post_data=None, streamed=False, files=None, **kwargs): """Make an HTTP request to the Gitlab server. @@ -416,27 +441,24 @@ class Gitlab(object): GitlabHttpError: When the return code is not 2xx """ - def sanitized_url(url): - parsed = six.moves.urllib.parse.urlparse(url) - new_path = parsed.path.replace('.', '%2E') - return parsed._replace(path=new_path).geturl() - url = self._build_url(path) - 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) + utils.copy_dict(params, query_data) + + # Deal with kwargs: by default a user uses kwargs to send data to the + # gitlab server, but this generates problems (python keyword conflicts + # and python-gitlab/gitlab conflicts). + # So we provide a `query_parameters` key: if it's there we use its dict + # value as arguments for the gitlab server, and ignore the other + # arguments, except pagination ones (per_page and page) + if 'query_parameters' in kwargs: + utils.copy_dict(params, kwargs['query_parameters']) + for arg in ('per_page', 'page'): + if arg in kwargs: + params[arg] = kwargs[arg] + else: + utils.copy_dict(params, kwargs) opts = self._get_session_opts(content_type='application/json') @@ -461,28 +483,42 @@ class Gitlab(object): req = requests.Request(verb, url, json=json, data=data, params=params, files=files, **opts) prepped = self.session.prepare_request(req) - prepped.url = sanitized_url(prepped.url) + prepped.url = utils.sanitized_url(prepped.url) settings = self.session.merge_environment_settings( prepped.url, {}, streamed, verify, None) # obey the rate limit by default obey_rate_limit = kwargs.get("obey_rate_limit", True) + # set max_retries to 10 by default, disable by setting it to -1 + max_retries = kwargs.get("max_retries", 10) + cur_retries = 0 + while True: result = self.session.send(prepped, timeout=timeout, **settings) + self._check_redirects(result) + if 200 <= result.status_code < 300: return result if 429 == result.status_code and obey_rate_limit: - wait_time = int(result.headers["Retry-After"]) - time.sleep(wait_time) - continue - + if max_retries == -1 or cur_retries < max_retries: + wait_time = 2 ** cur_retries * 0.1 + if "Retry-After" in result.headers: + wait_time = int(result.headers["Retry-After"]) + cur_retries += 1 + time.sleep(wait_time) + continue + + error_message = result.content try: - error_message = result.json()['message'] + error_json = result.json() + for k in ('message', 'error'): + if k in error_json: + error_message = error_json[k] except (KeyError, ValueError, TypeError): - error_message = result.content + pass if result.status_code == 401: raise GitlabAuthenticationError( @@ -494,7 +530,8 @@ class Gitlab(object): error_message=error_message, response_body=result.content) - def http_get(self, path, query_data={}, streamed=False, **kwargs): + def http_get(self, path, query_data={}, streamed=False, raw=False, + **kwargs): """Make a GET request to the Gitlab server. Args: @@ -502,6 +539,7 @@ class Gitlab(object): 'http://whatever/v4/api/projecs') query_data (dict): Data to send as query parameters streamed (bool): Whether the data should be streamed + raw (bool): If True do not try to parse the output as json **kwargs: Extra options to send to the server (e.g. sudo) Returns: @@ -515,8 +553,10 @@ class Gitlab(object): """ result = self.http_request('get', path, query_data=query_data, streamed=streamed, **kwargs) - if (result.headers['Content-Type'] == 'application/json' and - not streamed): + + if (result.headers['Content-Type'] == 'application/json' + and not streamed + and not raw): try: return result.json() except Exception: diff --git a/gitlab/cli.py b/gitlab/cli.py index 4870192..17917f5 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -17,6 +17,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from __future__ import print_function + import argparse import functools import importlib @@ -98,7 +99,7 @@ def _get_base_parser(add_help=True): "will be used."), required=False) parser.add_argument("-o", "--output", - help=("Output format (v4 only): json|legacy|yaml"), + help="Output format (v4 only): json|legacy|yaml", required=False, choices=['json', 'legacy', 'yaml'], default="legacy") @@ -135,13 +136,21 @@ def main(): exit(0) parser = _get_base_parser(add_help=False) + if "--help" in sys.argv or "-h" in sys.argv: + parser.print_help() + exit(0) + # This first parsing step is used to find the gitlab config to use, and # load the propermodule (v3 or v4) accordingly. At that point we don't have # any subparser setup (options, args) = parser.parse_known_args(sys.argv) - - config = gitlab.config.GitlabConfigParser(options.gitlab, - options.config_file) + try: + config = gitlab.config.GitlabConfigParser( + options.gitlab, + options.config_file + ) + except gitlab.config.ConfigError as e: + sys.exit(e) cli_module = importlib.import_module('gitlab.v%s.cli' % config.api_version) # Now we build the entire set of subcommands and do the complete parsing diff --git a/gitlab/config.py b/gitlab/config.py index 9f4c11d..1c76594 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -37,10 +37,27 @@ class GitlabDataError(ConfigError): pass +class GitlabConfigMissingError(ConfigError): + pass + + class GitlabConfigParser(object): def __init__(self, gitlab_id=None, config_files=None): self.gitlab_id = gitlab_id _files = config_files or _DEFAULT_FILES + file_exist = False + for file in _files: + if os.path.exists(file): + file_exist = True + if not file_exist: + raise GitlabConfigMissingError( + "Config file not found. \nPlease create one in " + "one of the following locations: {} \nor " + "specify a config file using the '-c' parameter.".format( + ", ".join(_DEFAULT_FILES) + ) + ) + self._config = configparser.ConfigParser() self._config.read(_files) diff --git a/gitlab/const.py b/gitlab/const.py index e4766d5..62f2403 100644 --- a/gitlab/const.py +++ b/gitlab/const.py @@ -18,7 +18,8 @@ GUEST_ACCESS = 10 REPORTER_ACCESS = 20 DEVELOPER_ACCESS = 30 -MASTER_ACCESS = 40 +MAINTAINER_ACCESS = 40 +MASTER_ACCESS = MAINTAINER_ACCESS OWNER_ACCESS = 50 VISIBILITY_PRIVATE = 0 diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index ddaef31..5b7b75c 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -28,7 +28,12 @@ class GitlabError(Exception): # Full http response self.response_body = response_body # Parsed error message from gitlab - self.error_message = error_message + try: + # if we receive str/bytes we try to convert to unicode/str to have + # consistent message types (see #616) + self.error_message = error_message.decode() + except Exception: + self.error_message = error_message def __str__(self): if self.response_code is not None: @@ -41,6 +46,10 @@ class GitlabAuthenticationError(GitlabError): pass +class RedirectError(GitlabError): + pass + + class GitlabParsingError(GitlabError): pass diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 2c80f36..ca68658 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -532,7 +532,7 @@ class TimeTrackingMixin(object): GitlabAuthenticationError: If authentication is not correct GitlabTimeTrackingError: If the time tracking update cannot be done """ - path = '%s/%s/rest_time_estimate' % (self.manager.path, self.get_id()) + path = '%s/%s/reset_time_estimate' % (self.manager.path, self.get_id()) return self.manager.gitlab.http_post(path, **kwargs) @cli.register_custom_action(('ProjectIssue', 'ProjectMergeRequest'), diff --git a/gitlab/tests/test_config.py b/gitlab/tests/test_config.py index 0b585e8..d1e668e 100644 --- a/gitlab/tests/test_config.py +++ b/gitlab/tests/test_config.py @@ -76,11 +76,20 @@ per_page = 200 class TestConfigParser(unittest.TestCase): + @mock.patch('os.path.exists') + def test_missing_config(self, path_exists): + path_exists.return_value = False + with self.assertRaises(config.GitlabConfigMissingError): + config.GitlabConfigParser('test') + + @mock.patch('os.path.exists') @mock.patch('six.moves.builtins.open') - def test_invalid_id(self, m_open): + def test_invalid_id(self, m_open, path_exists): fd = six.StringIO(no_default_config) fd.close = mock.Mock(return_value=None) m_open.return_value = fd + path_exists.return_value = True + config.GitlabConfigParser('there') self.assertRaises(config.GitlabIDError, config.GitlabConfigParser) fd = six.StringIO(valid_config) @@ -90,12 +99,15 @@ class TestConfigParser(unittest.TestCase): config.GitlabConfigParser, gitlab_id='not_there') + @mock.patch('os.path.exists') @mock.patch('six.moves.builtins.open') - def test_invalid_data(self, m_open): + def test_invalid_data(self, m_open, path_exists): fd = six.StringIO(missing_attr_config) fd.close = mock.Mock(return_value=None, side_effect=lambda: fd.seek(0)) m_open.return_value = fd + path_exists.return_value = True + config.GitlabConfigParser('one') config.GitlabConfigParser('one') self.assertRaises(config.GitlabDataError, config.GitlabConfigParser, @@ -107,11 +119,13 @@ class TestConfigParser(unittest.TestCase): self.assertEqual('Unsupported per_page number: 200', emgr.exception.args[0]) + @mock.patch('os.path.exists') @mock.patch('six.moves.builtins.open') - def test_valid_data(self, m_open): + def test_valid_data(self, m_open, path_exists): fd = six.StringIO(valid_config) fd.close = mock.Mock(return_value=None) m_open.return_value = fd + path_exists.return_value = True cp = config.GitlabConfigParser() self.assertEqual("one", cp.gitlab_id) diff --git a/gitlab/utils.py b/gitlab/utils.py index a449f81..49e2c88 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -15,6 +15,8 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import six + class _StdoutStream(object): def __call__(self, chunk): @@ -31,3 +33,21 @@ def response_content(response, streamed, action, chunk_size): for chunk in response.iter_content(chunk_size=chunk_size): if chunk: action(chunk) + + +def copy_dict(dest, src): + for k, v in src.items(): + if isinstance(v, dict): + # Transform dict values to 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 + + +def sanitized_url(url): + parsed = six.moves.urllib.parse.urlparse(url) + new_path = parsed.path.replace('.', '%2E') + return parsed._replace(path=new_path).geturl() diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index 880b07d..242874d 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -19,6 +19,7 @@ from __future__ import print_function import inspect import operator +import sys import six @@ -54,11 +55,18 @@ class GitlabCLI(object): self.args[attr_name] = obj.get() def __call__(self): + # Check for a method that matches object + action + method = 'do_%s_%s' % (self.what, self.action) + if hasattr(self, method): + return getattr(self, method)() + + # Fallback to standard actions (get, list, create, ...) method = 'do_%s' % self.action if hasattr(self, method): return getattr(self, method)() - else: - return self.do_custom() + + # Finally try to find custom methods + return self.do_custom() def do_custom(self): in_obj = cli.custom_actions[self.cls_name][self.action][2] @@ -77,6 +85,20 @@ class GitlabCLI(object): else: return getattr(self.mgr, self.action)(**self.args) + def do_project_export_download(self): + try: + project = self.gl.projects.get(int(self.args['project_id']), + lazy=True) + data = project.exports.get().download() + if hasattr(sys.stdout, 'buffer'): + # python3 + sys.stdout.buffer.write(data) + else: + sys.stdout.write(data) + + except Exception as e: + cli.die("Impossible to download the export", e) + def do_create(self): try: return self.mgr.create(self.args) @@ -280,14 +302,24 @@ class JSONPrinter(object): class YAMLPrinter(object): def display(self, d, **kwargs): - import yaml # noqa - print(yaml.safe_dump(d, default_flow_style=False)) + try: + import yaml # noqa + print(yaml.safe_dump(d, default_flow_style=False)) + except ImportError: + exit("PyYaml is not installed.\n" + "Install it with `pip install PyYaml` " + "to use the yaml output feature") def display_list(self, data, fields, **kwargs): - import yaml # noqa - print(yaml.safe_dump( - [get_dict(obj, fields) for obj in data], - default_flow_style=False)) + try: + import yaml # noqa + print(yaml.safe_dump( + [get_dict(obj, fields) for obj in data], + default_flow_style=False)) + except ImportError: + exit("PyYaml is not installed.\n" + "Install it with `pip install PyYaml` " + "to use the yaml output feature") class LegacyPrinter(object): @@ -366,3 +398,5 @@ def run(gl, what, action, args, verbose, output, fields): printer.display(get_dict(data, fields), verbose=verbose, obj=data) elif isinstance(data, six.string_types): print(data) + elif hasattr(data, 'decode'): + print(data.decode()) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index fdd02ae..af61488 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -662,9 +662,22 @@ class GroupEpicIssueManager(ListMixin, CreateMixin, UpdateMixin, DeleteMixin, return self._obj_cls(self, server_data) +class GroupEpicResourceLabelEvent(RESTObject): + pass + + +class GroupEpicResourceLabelEventManager(RetrieveMixin, RESTManager): + _path = ('/groups/%(group_id)s/epics/%(epic_id)s/resource_label_events') + _obj_cls = GroupEpicResourceLabelEvent + _from_parent_attrs = {'group_id': 'group_id', 'epic_id': 'id'} + + class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject): _id_attr = 'iid' - _managers = (('issues', 'GroupEpicIssueManager'),) + _managers = ( + ('issues', 'GroupEpicIssueManager'), + ('resourcelabelevents', 'GroupEpicResourceLabelEventManager'), + ) class GroupEpicManager(CRUDMixin, RESTManager): @@ -705,13 +718,45 @@ class GroupMemberManager(CRUDMixin, RESTManager): _create_attrs = (('access_level', 'user_id'), ('expires_at', )) _update_attrs = (('access_level', ), ('expires_at', )) + @cli.register_custom_action('GroupMemberManager') + @exc.on_http_error(exc.GitlabListError) + def all(self, **kwargs): + """List all the members, included inherited ones. + + 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 members + """ + + path = '%s/all' % self.path + return self.gitlab.http_list(path, **kwargs) + class GroupMergeRequest(RESTObject): pass -class GroupMergeRequestManager(RESTManager): - pass +class GroupMergeRequestManager(ListMixin, RESTManager): + _path = '/groups/%(group_id)s/merge_requests' + _obj_cls = GroupMergeRequest + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('state', 'order_by', 'sort', 'milestone', 'view', + 'labels', 'created_after', 'created_before', + 'updated_after', 'updated_before', 'scope', 'author_id', + 'assignee_id', 'my_reaction_emoji', 'source_branch', + 'target_branch', 'search') + _types = {'labels': types.ListAttribute} class GroupMilestone(SaveMixin, ObjectDeleteMixin, RESTObject): @@ -842,6 +887,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject): ('epics', 'GroupEpicManager'), ('issues', 'GroupIssueManager'), ('members', 'GroupMemberManager'), + ('mergerequests', 'GroupMergeRequestManager'), ('milestones', 'GroupMilestoneManager'), ('notificationsettings', 'GroupNotificationSettingsManager'), ('projects', 'GroupProjectManager'), @@ -1040,6 +1086,22 @@ class LicenseManager(RetrieveMixin, RESTManager): _optional_get_attrs = ('project', 'fullname') +class MergeRequest(RESTObject): + pass + + +class MergeRequestManager(ListMixin, RESTManager): + _path = '/merge_requests' + _obj_cls = MergeRequest + _from_parent_attrs = {'group_id': 'id'} + _list_filters = ('state', 'order_by', 'sort', 'milestone', 'view', + 'labels', 'created_after', 'created_before', + 'updated_after', 'updated_before', 'scope', 'author_id', + 'assignee_id', 'my_reaction_emoji', 'source_branch', + 'target_branch', 'search') + _types = {'labels': types.ListAttribute} + + class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'title' @@ -1066,7 +1128,7 @@ class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject): """ path = '/snippets/%s/raw' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -1303,7 +1365,7 @@ class ProjectJob(RESTObject, RefreshMixin): """ path = '%s/%s/artifacts' % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('ProjectJob') @@ -1331,7 +1393,7 @@ class ProjectJob(RESTObject, RefreshMixin): """ path = '%s/%s/artifacts/%s' % (self.manager.path, self.get_id(), path) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('ProjectJob') @@ -1357,7 +1419,7 @@ class ProjectJob(RESTObject, RefreshMixin): """ path = '%s/%s/trace' % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -1620,7 +1682,7 @@ class ProjectFork(RESTObject): pass -class ProjectForkManager(CreateMixin, RESTManager): +class ProjectForkManager(CreateMixin, ListMixin, RESTManager): _path = '/projects/%(project_id)s/fork' _obj_cls = ProjectFork _from_parent_attrs = {'project_id': 'id'} @@ -1630,6 +1692,28 @@ class ProjectForkManager(CreateMixin, RESTManager): 'with_merge_requests_enabled') _create_attrs = (tuple(), ('namespace', )) + 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 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 = self._compute_path('/projects/%(project_id)s/forks') + return ListMixin.list(self, path=path, **kwargs) + class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject): _short_print_attr = 'url' @@ -1756,6 +1840,17 @@ class ProjectIssueLinkManager(ListMixin, CreateMixin, DeleteMixin, return source_issue, target_issue +class ProjectIssueResourceLabelEvent(RESTObject): + pass + + +class ProjectIssueResourceLabelEventManager(RetrieveMixin, RESTManager): + _path = ('/projects/%(project_id)s/issues/%(issue_iid)s' + '/resource_label_events') + _obj_cls = ProjectIssueResourceLabelEvent + _from_parent_attrs = {'project_id': 'project_id', 'issue_iid': 'iid'} + + class ProjectIssue(UserAgentDetailMixin, SubscribableMixin, TodoMixin, TimeTrackingMixin, ParticipantsMixin, SaveMixin, ObjectDeleteMixin, RESTObject): @@ -1766,6 +1861,7 @@ class ProjectIssue(UserAgentDetailMixin, SubscribableMixin, TodoMixin, ('discussions', 'ProjectIssueDiscussionManager'), ('links', 'ProjectIssueLinkManager'), ('notes', 'ProjectIssueNoteManager'), + ('resourcelabelevents', 'ProjectIssueResourceLabelEventManager'), ) @cli.register_custom_action('ProjectIssue', ('to_project_id',)) @@ -1815,8 +1911,8 @@ class ProjectIssueManager(CRUDMixin, RESTManager): 'order_by', 'sort', 'search', 'created_after', 'created_before', 'updated_after', 'updated_before') _create_attrs = (('title', ), - ('description', 'confidential', 'assignee_id', - 'assignee_idss' 'milestone_id', 'labels', 'created_at', + ('description', 'confidential', 'assignee_ids', + 'assignee_id', 'milestone_id', 'labels', 'created_at', 'due_date', 'merge_request_to_resolve_discussions_of', 'discussion_to_resolve')) _update_attrs = (tuple(), ('title', 'description', 'confidential', @@ -1837,6 +1933,30 @@ class ProjectMemberManager(CRUDMixin, RESTManager): _create_attrs = (('access_level', 'user_id'), ('expires_at', )) _update_attrs = (('access_level', ), ('expires_at', )) + @cli.register_custom_action('ProjectMemberManager') + @exc.on_http_error(exc.GitlabListError) + def all(self, **kwargs): + """List all the members, included inherited ones. + + 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 members + """ + + path = '%s/all' % self.path + return self.gitlab.http_list(path, **kwargs) + class ProjectNote(RESTObject): pass @@ -1918,6 +2038,18 @@ class ProjectTagManager(NoUpdateMixin, RESTManager): _create_attrs = (('tag_name', 'ref'), ('message',)) +class ProjectProtectedTag(ObjectDeleteMixin, RESTObject): + _id_attr = 'name' + _short_print_attr = 'name' + + +class ProjectProtectedTagManager(NoUpdateMixin, RESTManager): + _path = '/projects/%(project_id)s/protected_tags' + _obj_cls = ProjectProtectedTag + _from_parent_attrs = {'project_id': 'id'} + _create_attrs = (('name',), ('create_access_level',)) + + class ProjectMergeRequestApproval(SaveMixin, RESTObject): _id_attr = None @@ -2027,6 +2159,17 @@ class ProjectMergeRequestDiscussionManager(RetrieveMixin, CreateMixin, _update_attrs = (('resolved',), tuple()) +class ProjectMergeRequestResourceLabelEvent(RESTObject): + pass + + +class ProjectMergeRequestResourceLabelEventManager(RetrieveMixin, RESTManager): + _path = ('/projects/%(project_id)s/merge_requests/%(mr_iid)s' + '/resource_label_events') + _obj_cls = ProjectMergeRequestResourceLabelEvent + _from_parent_attrs = {'project_id': 'project_id', 'mr_iid': 'iid'} + + class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, ParticipantsMixin, SaveMixin, ObjectDeleteMixin, RESTObject): @@ -2038,6 +2181,8 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, ('diffs', 'ProjectMergeRequestDiffManager'), ('discussions', 'ProjectMergeRequestDiscussionManager'), ('notes', 'ProjectMergeRequestNoteManager'), + ('resourcelabelevents', + 'ProjectMergeRequestResourceLabelEventManager'), ) @cli.register_custom_action('ProjectMergeRequest') @@ -2217,13 +2362,14 @@ class ProjectMergeRequestManager(CRUDMixin, RESTManager): _create_attrs = ( ('source_branch', 'target_branch', 'title'), ('assignee_id', 'description', 'target_project_id', 'labels', - 'milestone_id', 'remove_source_branch', 'allow_maintainer_to_push') + 'milestone_id', 'remove_source_branch', 'allow_maintainer_to_push', + 'squash') ) - _update_attrs = (tuple(), - ('target_branch', 'assignee_id', 'title', 'description', - 'state_event', 'labels', 'milestone_id', - 'remove_source_branch', 'discussion_locked', - 'allow_maintainer_to_push')) + _update_attrs = ( + tuple(), + ('target_branch', 'assignee_id', 'title', 'description', 'state_event', + 'labels', 'milestone_id', 'remove_source_branch', 'discussion_locked', + 'allow_maintainer_to_push', 'squash')) _list_filters = ('state', 'order_by', 'sort', 'milestone', 'view', 'labels', 'created_after', 'created_before', 'updated_after', 'updated_before', 'scope', 'author_id', @@ -2531,7 +2677,7 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin, path = '%s/%s/raw' % (self.path, file_path) query_data = {'ref': ref} result = self.gitlab.http_get(path, query_data=query_data, - streamed=streamed, **kwargs) + streamed=streamed, raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -2774,7 +2920,7 @@ class ProjectSnippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, """ path = "%s/%s/raw" % (self.manager.path, self.get_id()) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -2994,7 +3140,10 @@ class ProjectProtectedBranchManager(NoUpdateMixin, RESTManager): _path = '/projects/%(project_id)s/protected_branches' _obj_cls = ProjectProtectedBranch _from_parent_attrs = {'project_id': 'id'} - _create_attrs = (('name', ), ('push_access_level', 'merge_access_level')) + _create_attrs = (('name', ), + ('push_access_level', 'merge_access_level', + 'unprotect_access_level', 'allowed_to_push', + 'allowed_to_merge', 'allowed_to_unprotect')) class ProjectRunner(ObjectDeleteMixin, RESTObject): @@ -3048,7 +3197,7 @@ class ProjectExport(RefreshMixin, RESTObject): """ path = '/projects/%d/export/download' % self.project_id result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @@ -3099,6 +3248,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('pagesdomains', 'ProjectPagesDomainManager'), ('pipelines', 'ProjectPipelineManager'), ('protectedbranches', 'ProjectProtectedBranchManager'), + ('protectedtags', 'ProjectProtectedTagManager'), ('pipelineschedules', 'ProjectPipelineScheduleManager'), ('pushrules', 'ProjectPushRulesManager'), ('runners', 'ProjectRunnerManager'), @@ -3188,7 +3338,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): """ path = '/projects/%s/repository/blobs/%s/raw' % (self.get_id(), sha) result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('Project', ('from_', 'to')) @@ -3264,7 +3414,8 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): if sha: query_data['sha'] = sha result = self.manager.gitlab.http_get(path, query_data=query_data, - streamed=streamed, **kwargs) + raw=True, streamed=streamed, + **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('Project', ('forked_from_id', )) @@ -3547,7 +3698,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): """ path = '/projects/%d/snapshot' % self.get_id() result = self.manager.gitlab.http_get(path, streamed=streamed, - **kwargs) + raw=True, **kwargs) return utils.response_content(result, streamed, action, chunk_size) @cli.register_custom_action('Project', ('scope', 'search')) @@ -3586,6 +3737,25 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): path = '/projects/%d/mirror/pull' % self.get_id() self.manager.gitlab.http_post(path, **kwargs) + @cli.register_custom_action('Project', ('to_namespace', )) + @exc.on_http_error(exc.GitlabTransferProjectError) + def transfer_project(self, to_namespace, **kwargs): + """Transfer a project to the given namespace ID + + Args: + to_namespace (str): ID or path of the namespace to transfer the + project to + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabTransferProjectError: If the project could not be transfered + """ + path = '/projects/%d/transfer' % (self.id,) + self.manager.gitlab.http_put(path, + post_data={"namespace": to_namespace}, + **kwargs) + class ProjectManager(CRUDMixin, RESTManager): _path = '/projects' @@ -3646,7 +3816,8 @@ class ProjectManager(CRUDMixin, RESTManager): 'overwrite': overwrite } if override_params: - data['override_params'] = override_params + for k, v in override_params.items(): + data['override_params[%s]' % k] = v if namespace: data['namespace'] = namespace return self.gitlab.http_post('/projects/import', post_data=data, @@ -11,11 +11,13 @@ def get_version(): if line.startswith('__version__'): return eval(line.split('=')[-1]) +with open("README.rst", "r") as readme_file: + readme = readme_file.read() setup(name='python-gitlab', version=get_version(), description='Interact with GitLab API', - long_description='Interact with GitLab API', + long_description=readme, author='Gauvain Pocentek', author_email='gauvain@pocentek.net', license='LGPLv3', diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index ebfb80a..3185f72 100755 --- a/tools/build_test_env.sh +++ b/tools/build_test_env.sh @@ -77,7 +77,7 @@ cleanup() { } try docker run --name gitlab-test --detach --publish 8080:80 \ - --publish 2222:22 gpocentek/test-python-gitlab:latest >/dev/null + --publish 2222:22 pythongitlab/test-python-gitlab:latest >/dev/null LOGIN='root' PASSWORD='5iveL!fe' diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 3b54936..958e350 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -244,6 +244,7 @@ assert(len(group2.members.list()) == 2) group1.members.delete(user1.id) assert(len(group1.members.list()) == 2) +assert(len(group1.members.all())) member = group1.members.get(user2.id) member.access_level = gitlab.const.OWNER_ACCESS member.save() @@ -390,7 +391,7 @@ data = { ] } admin_project.commits.create(data) -assert('---' in admin_project.commits.list()[0].diff()[0]['diff']) +assert('@@' in admin_project.commits.list()[0].diff()[0]['diff']) # commit status commit = admin_project.commits.list()[0] @@ -467,6 +468,9 @@ fork = admin_project.forks.create({'namespace': user1.username}) p = gl.projects.get(fork.id) assert(p.forked_from_project['id'] == admin_project.id) +forks = admin_project.forks.list() +assert(fork.id in map(lambda p: p.id, forks)) + # project hooks hook = admin_project.hooks.create({'url': 'http://hook.url'}) assert(len(admin_project.hooks.list()) == 1) @@ -536,6 +540,15 @@ assert(isinstance(issue1.user_agent_detail(), dict)) assert(issue1.user_agent_detail()['user_agent']) assert(issue1.participants()) +# issues labels and events +label2 = admin_project.labels.create({'name': 'label2', 'color': '#aabbcc'}) +issue1.labels = ['label2'] +issue1.save() +events = issue1.resourcelabelevents.list() +assert(events) +event = issue1.resourcelabelevents.get(events[0].id) +assert(event) + discussion = issue1.discussions.create({'body': 'Discussion body'}) assert(len(issue1.discussions.list()) == 1) d_note = discussion.notes.create({'body': 'first note'}) @@ -625,6 +638,14 @@ d_note_from_get.delete() discussion = mr.discussions.get(discussion.id) assert(len(discussion.attributes['notes']) == 1) +# mr labels and events +mr.labels = ['label2'] +mr.save() +events = mr.resourcelabelevents.list() +assert(events) +event = mr.resourcelabelevents.get(events[0].id) +assert(event) + # basic testing: only make sure that the methods exist mr.commits() mr.changes() @@ -752,7 +773,7 @@ snippets = gl.snippets.list(all=True) assert(len(snippets) == 0) # user activities -gl.user_activities.list() +gl.user_activities.list(query_parameters={'from': '2019-01-01'}) # events gl.events.list() @@ -777,7 +798,7 @@ for i in range(20, 40): except gitlab.GitlabCreateError as e: error_message = e.error_message break -assert 'Retry later' in error_message.decode() +assert 'Retry later' in error_message [current_project.delete() for current_project in projects] settings.throttle_authenticated_api_enabled = False settings.save() |