summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.dockerignore5
-rw-r--r--.travis.yml9
-rw-r--r--AUTHORS102
-rw-r--r--ChangeLog.rst46
-rw-r--r--Dockerfile17
-rw-r--r--README.rst34
-rw-r--r--RELEASE_NOTES.rst30
-rw-r--r--contrib/docker/Dockerfile10
-rw-r--r--contrib/docker/README.rst19
-rw-r--r--contrib/docker/python-gitlab.cfg15
-rwxr-xr-xdocker-entrypoint.sh21
-rw-r--r--docs/api-usage.rst52
-rw-r--r--docs/cli.rst12
-rw-r--r--docs/faq.rst33
-rw-r--r--docs/gl_objects/access_requests.rst4
-rw-r--r--docs/gl_objects/builds.rst7
-rw-r--r--docs/gl_objects/commits.rst12
-rw-r--r--docs/gl_objects/discussions.rst6
-rw-r--r--docs/gl_objects/groups.rst31
-rw-r--r--docs/gl_objects/issues.rst24
-rw-r--r--docs/gl_objects/labels.rst39
-rw-r--r--docs/gl_objects/milestones.rst2
-rw-r--r--docs/gl_objects/mrs.rst57
-rw-r--r--docs/gl_objects/projects.rst52
-rw-r--r--docs/gl_objects/protected_branches.rst11
-rw-r--r--docs/gl_objects/users.rst12
-rw-r--r--docs/index.rst1
-rw-r--r--docs/switching-to-v4.rst2
-rw-r--r--gitlab/__init__.py98
-rw-r--r--gitlab/cli.py17
-rw-r--r--gitlab/config.py17
-rw-r--r--gitlab/const.py3
-rw-r--r--gitlab/exceptions.py11
-rw-r--r--gitlab/mixins.py2
-rw-r--r--gitlab/tests/test_config.py20
-rw-r--r--gitlab/utils.py20
-rw-r--r--gitlab/v4/cli.py50
-rw-r--r--gitlab/v4/objects.py219
-rw-r--r--setup.py4
-rwxr-xr-xtools/build_test_env.sh2
-rw-r--r--tools/python_test_v4.py27
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
diff --git a/AUTHORS b/AUTHORS
index 14cb986..f255ad7 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -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"]
diff --git a/README.rst b/README.rst
index 56856b6..393398e 100644
--- a/README.rst
+++ b/README.rst
@@ -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,
diff --git a/setup.py b/setup.py
index 02773eb..b592e7c 100644
--- a/setup.py
+++ b/setup.py
@@ -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()