diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..204be7425 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +venv/ +dist/ +build/ +*.egg-info +.github/ diff --git a/AUTHORS b/AUTHORS index 11ae684ba..f255ad788 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,105 +1,10 @@ -Authors -------- +Authors / Maintainers +--------------------- -Gauvain Pocentek -Mika Mäenpää +Gauvain Pocentek +Max Wittig Contributors ------------ -Adam Reid -Alexander Skiba -Alex Widener -Amar Sood (tekacs) -Andjelko Horvat -Andreas Nüßlein -Andrew Austin -Armin Weihbold -Aron Pammer -Asher256 -Bancarel Valentin -Ben Brown -btmanm -Carlo Mion -Carlos Soriano -Christian -Christian Wenk -Colin D Bennett -Cosimo Lupo -Crestez Dan Leonard -Cyril Jouve -Daniel Kimsey -David Guest -derek-austin -Diego Giovane Pasqualin -Dmytro Litvinov -Eli Sarver -Eric L Frederich -Eric Sabouraud -Erik Weatherwax -fgouteroux -Greg Allen -Guillaume Delacour -Guyzmo -hakkeroid -Ian Sparks -itxaka -Ivica Arsov -Jakub Wilk -James (d0c_s4vage) Johnson -James E. Flemer -James Johnson -Jamie Bliss -Jason Antman -Jerome Robert -Johan Brandhorst -Jonathon Reinhart -Jon Banafato -Keith Wansbrough -Koen Smets -Kris Gambirazzi -leon -Lyudmil Nenov -Mart Sõmermaa -massimone88 -Matej Zerovnik -Matt Odden -Matthias Schmitz -Matus Ferech -Maura Hausman -Maxime Guyot -Max Wittig -Michael Overmeyer -Michal Galet -Mike Kobit -Mikhail Lopotkov -Miouge1 -Missionrulz -Mond WAN -Moritz Lipp -Nathan Giesbrecht -Nathan Schmidt -pa4373 -Patrick Miller -Pavel Savchenko -Peng Xiao -Pete Browne -Peter Mosmans -P. F. Chimento -Philipp Busch -Pierre Tardy -Rafael Eyng -Richard Hansen -Robert Lu -samcday -savenger -Stefan Crain -Stefan K. Dunkler -Stefan Klug -Stefano Mandruzzato -THEBAULT Julien -Tim Neumann -Tom Downes -Twan -Will Rouesnel -Will Starms -Yosi Zelensky + +See ``git log`` for a full list of contributors. diff --git a/ChangeLog.rst b/ChangeLog.rst index beac7ff94..3e96318fd 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,6 +1,31 @@ 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 --------------------------- @@ -660,6 +685,7 @@ Version 0.1 - 2013-07-08 * Initial release +.. _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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..489a4207a --- /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 56856b6c6..bed7f0eee 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= -v /path/to/python-gitlab.cfg:/python-gitlab.cfg python-gitlab ...`` + +To change the GitLab URL, use `-e GITLAB_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 ...`` + + Bug reports =========== @@ -48,6 +72,13 @@ Documentation The full documentation for CLI and API is available on `readthedocs `_. +Build the docs +-------------- +You can build the documentation using ``sphinx``:: + + pip install sphinx + python setup.py build_sphinx + Contributing ============ diff --git a/contrib/docker/Dockerfile b/contrib/docker/Dockerfile deleted file mode 100644 index 6663cac5d..000000000 --- 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 90a576cf4..000000000 --- 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 ...`` - -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 0e519545f..000000000 --- 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 000000000..bda814171 --- /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 fa6e0b0da..73d137732 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -2,7 +2,7 @@ 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:: @@ -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 ------------------------------- @@ -187,7 +192,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: diff --git a/docs/cli.rst b/docs/cli.rst index 95fa6f448..2051d0373 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -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 `_ 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/gl_objects/access_requests.rst b/docs/gl_objects/access_requests.rst index 9a147c140..e890ce07f 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 51e7496c1..ee450905a 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/discussions.rst b/docs/gl_objects/discussions.rst index 7673b7c2d..444d883a8 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 5ef54690a..7fcf980b6 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/labels.rst b/docs/gl_objects/labels.rst index 1c98971c2..a4667aac0 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 0d3f576d5..f24e13fc7 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/projects.rst b/docs/gl_objects/projects.rst index 7092fe66f..a00aae07f 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') @@ -247,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': @@ -478,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') @@ -493,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:: @@ -657,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 bd2b22b87..3498aa578 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 3b9c040fa..d86d2ed30 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:: diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst index ef2106088..e6490e3f8 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 6afccf2dc..01f9426d7 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -31,7 +31,7 @@ from gitlab import utils # noqa __title__ = 'python-gitlab' -__version__ = '1.6.0' +__version__ = '1.7.0' __author__ = 'Gauvain Pocentek' __email__ = 'gauvain@pocentek.net' __license__ = 'LGPL3' @@ -411,7 +411,7 @@ def _check_redirects(self, result): if item.status_code not in (301, 302): continue # GET methods can be redirected without issue - if result.request.method == 'GET': + if item.request.method == 'GET': continue # Did we end-up with an https:// URL? location = item.headers.get('Location', None) @@ -490,10 +490,14 @@ def http_request(self, verb, path, query_data={}, post_data=None, 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( diff --git a/gitlab/cli.py b/gitlab/cli.py index 48701922c..17917f564 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -17,6 +17,7 @@ # along with this program. If not, see . 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 9f4c11d7b..1c7659498 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 e4766d596..62f240391 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 650328a15..0822d3e58 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -28,7 +28,12 @@ def __init__(self, error_message="", response_code=None, # 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: diff --git a/gitlab/tests/test_config.py b/gitlab/tests/test_config.py index 0b585e801..d1e668efc 100644 --- a/gitlab/tests/test_config.py +++ b/gitlab/tests/test_config.py @@ -76,11 +76,20 @@ 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 @@ def test_invalid_id(self, m_open): 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 @@ def test_invalid_data(self, m_open): 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/v4/cli.py b/gitlab/v4/cli.py index a876f9ee6..242874d1a 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -302,14 +302,24 @@ def display_list(self, data, fields, **kwargs): 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): diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index bd7635fd1..fd673b522 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -662,9 +662,22 @@ def create(self, data, **kwargs): 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,6 +718,30 @@ 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 @@ -1803,6 +1840,17 @@ def create(self, data, **kwargs): 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): @@ -1813,6 +1861,7 @@ class ProjectIssue(UserAgentDetailMixin, SubscribableMixin, TodoMixin, ('discussions', 'ProjectIssueDiscussionManager'), ('links', 'ProjectIssueLinkManager'), ('notes', 'ProjectIssueNoteManager'), + ('resourcelabelevents', 'ProjectIssueResourceLabelEventManager'), ) @cli.register_custom_action('ProjectIssue', ('to_project_id',)) @@ -1862,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', @@ -1884,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 @@ -1965,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 @@ -2074,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): @@ -2085,6 +2181,8 @@ class ProjectMergeRequest(SubscribableMixin, TodoMixin, TimeTrackingMixin, ('diffs', 'ProjectMergeRequestDiffManager'), ('discussions', 'ProjectMergeRequestDiscussionManager'), ('notes', 'ProjectMergeRequestNoteManager'), + ('resourcelabelevents', + 'ProjectMergeRequestResourceLabelEventManager'), ) @cli.register_custom_action('ProjectMergeRequest') @@ -3019,7 +3117,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): @@ -3124,6 +3225,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject): ('pagesdomains', 'ProjectPagesDomainManager'), ('pipelines', 'ProjectPipelineManager'), ('protectedbranches', 'ProjectProtectedBranchManager'), + ('protectedtags', 'ProjectProtectedTagManager'), ('pipelineschedules', 'ProjectPipelineScheduleManager'), ('pushrules', 'ProjectPushRulesManager'), ('runners', 'ProjectRunnerManager'), diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh index ebfb80a07..3185f72ce 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 79a78bc32..30e4456dc 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -244,6 +244,7 @@ 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 @@ ] } 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] @@ -539,6 +540,15 @@ 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'}) @@ -628,6 +638,14 @@ 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()