From 48b028c569d23ddf6eaeefb6247110983b4229c2 Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 17 Mar 2017 13:00:19 -0500 Subject: [PATCH 1/7] added project upload features. Needs unit tests still. see #56 --- gitlab/__init__.py | 4 +- gitlab/exceptions.py | 8 ++++ gitlab/objects.py | 95 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 119dab080..d7958c52c 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -340,11 +340,11 @@ def _raw_list(self, path_, cls, extra_attrs={}, **kwargs): results.extend(self.list(cls, **args)) return results - def _raw_post(self, path_, data=None, content_type=None, **kwargs): + def _raw_post(self, path_, data=None, content_type=None, files=None, **kwargs): url = '%s%s' % (self._url, path_) opts = self._get_session_opts(content_type) try: - return self.session.post(url, params=kwargs, data=data, **opts) + return self.session.post(url, params=kwargs, data=data, files=files, **opts) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 11bbe26cb..a36046bce 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -147,6 +147,14 @@ class GitlabTimeTrackingError(GitlabOperationError): pass +class GitlabUploadError(GitlabOperationError): + pass + + +class GitlabAttachFileError(GitlabOperationError): + pass + + def raise_error_from_response(response, error, expected_code=200): """Tries to parse gitlab error message from response and raises error. diff --git a/gitlab/objects.py b/gitlab/objects.py index efe75d0a6..daf5ed256 100644 --- a/gitlab/objects.py +++ b/gitlab/objects.py @@ -562,6 +562,55 @@ def compound_metrics(self, **kwargs): return self._simple_get('/sidekiq/compound_metrics', **kwargs) +class ProjectUploadable(object): + """A mixin for objects that allow files to be uploaded/attached. + """ + + description_attr = "description" + """The attribute of the object that newly uploaded files' + markdown should be appended to. + """ + + project_id_attr = "project_id" + """The attribute that specifies the attribute that contains + the project id into which files may be uploaded. + """ + + # see #56 - add file uploading/attachment features + def attach_file(self, filename, filedata, **kwargs): + """Attach a file to the issue + + Args: + filename (str): The filename of the file being uploaded. + filedata (str): The raw data of the file being uploaded. + Raises: + GitlabConnectionError: If the server cannot be reached. + """ + project_id = getattr(self, self.project_id_attr, None) + if project_id is None: + raise GitlabAttachFileError("{}'s project id could not be determined (tried using {!r})".format( + self, + self.project_id_attr, + )) + + project = self.gitlab.projects.get(project_id) + res = project.upload( + filename = filename, + filedata = filedata, + ) + + orig_desc = getattr(self, self.description_attr, "") + # files are "attached" to issues and comments by uploading the + # the file into the project, and including the returned + # markdown in the description + setattr(self, self.description_attr, orig_desc + "\n\n" + res["markdown"]) + + # XXX:TODO: Is this correct? any changes to the current object instance + # that have not yet been saved will be saved along with the + # file upload, which *may not* be what the user intended. + self.save() + + class UserEmail(GitlabObject): _url = '/users/%(user_id)s/emails' canUpdate = False @@ -1437,7 +1486,7 @@ class ProjectHookManager(BaseManager): obj_cls = ProjectHook -class ProjectIssueNote(GitlabObject): +class ProjectIssueNote(GitlabObject, ProjectUploadable): _url = '/projects/%(project_id)s/issues/%(issue_id)s/notes' _constructorTypes = {'author': 'User'} canDelete = False @@ -1445,12 +1494,16 @@ class ProjectIssueNote(GitlabObject): requiredCreateAttrs = ['body'] optionalCreateAttrs = ['created_at'] + # file attachment settings (see #56) + description_attr = "body" + project_id_attr = "project_id" + class ProjectIssueNoteManager(BaseManager): obj_cls = ProjectIssueNote -class ProjectIssue(GitlabObject): +class ProjectIssue(GitlabObject, ProjectUploadable): _url = '/projects/%(project_id)s/issues/' _constructorTypes = {'author': 'User', 'assignee': 'User', 'milestone': 'ProjectMilestone'} @@ -1469,6 +1522,10 @@ class ProjectIssue(GitlabObject): [('project_id', 'project_id'), ('issue_id', 'id')]), ) + # file attachment settings (see #56) + description_attr = "description" + project_id_attr = "project_id" + def subscribe(self, **kwargs): """Subscribe to an issue. @@ -1593,6 +1650,7 @@ class ProjectIssueManager(BaseManager): class ProjectMember(GitlabObject): _url = '/projects/%(project_id)s/members' + requiredUrlAttrs = ['project_id'] requiredCreateAttrs = ['access_level', 'user_id'] optionalCreateAttrs = ['expires_at'] @@ -2553,6 +2611,39 @@ def trigger_build(self, ref, token, variables={}, **kwargs): r = self.gitlab._raw_post(url, data=data, **kwargs) raise_error_from_response(r, GitlabCreateError, 201) + # see #56 - add file attachment features + def upload(self, filename, filedata, **kwargs): + """Upload a file into the project. This will return the raw response + from the gitlab API in the form: + + { + "alt": "dk", + "url": "/uploads/66dbcd21ec5d24ed6ea225176098d52b/dk.png", + "markdown": "![dk](/uploads/66dbcd21ec5d24ed6ea225176098d52b/dk.png)" + } + + See https://github.com/gitlabhq/gitlabhq/blob/master/doc/api/projects.md#upload-a-file + for more information. + + Args: + filename (str): The name of the file being uploaded + filedata (bytes): The raw data of the file being uploaded + + Raises: + GitlabConnectionError: If the server cannot be reached + GitlabUploadError: If the file upload fails + """ + url = ("/projects/%(id)s/uploads" % { + "id": self.id, + }) + r = self.gitlab._raw_post( + url, + files = {"file": (filename, filedata)}, + ) + # returns 201 status code (created) + raise_error_from_response(r, GitlabUploadError, expected_code=201) + return r.json() + class Runner(GitlabObject): _url = '/runners' From 62f71bca1995985747b393c7e32b1447d7185412 Mon Sep 17 00:00:00 2001 From: "James (d0c_s4vage) Johnson" Date: Tue, 22 Aug 2017 16:38:30 -0500 Subject: [PATCH 2/7] File upload should be mostly complete see #56 * file uploads are not directly associated with issues/notes * should work with v3 and v4 apis * CLIs seem to be working --- docs/gl_objects/projects.py | 26 ++++++++ docs/gl_objects/projects.rst | 48 ++++++++++++++ gitlab/__init__.py | 13 ++-- gitlab/base.py | 28 +++++++- gitlab/v3/cli.py | 17 ++++- gitlab/v3/objects.py | 123 +++++++++++++++++------------------ gitlab/v4/cli.py | 2 +- gitlab/v4/objects.py | 82 +++++++++++++++++++++++ tools/cli_test_v3.sh | 4 ++ tools/cli_test_v4.sh | 4 ++ tools/python_test_v3.py | 16 +++++ tools/python_test_v4.py | 15 +++++ 12 files changed, 306 insertions(+), 72 deletions(-) diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index 131f43c66..0f0a01c5c 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -368,3 +368,29 @@ # board lists delete b_list.delete() # end board lists delete + +# project file upload by path +# Or provide a full path to the uploaded file +project.upload("filename.txt", filepath="/some/path/filename.txt") +# end project file upload by path + +# project file upload with data +# Upload a file using its filename and filedata +project.upload("filename.txt", filedata="Raw data") +# end project file upload with data + +# project file upload markdown +uploaded_file = project.upload_file("filename.txt", filedata="data") +issue = project.issues.get(issue_id) +issue.notes.create({ + "body": "See the attached file: {}".format(uploaded_file) +}) +# project file upload markdown + +# project file upload markdown custom +uploaded_file = project.upload_file("filename.txt", filedata="data") +issue = project.issues.get(issue_id) +issue.notes.create({ + "body": "See the [attached file]({})".format(uploaded_file.url) +}) +# project file upload markdown diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index 4a8a0ad27..b6cf311c5 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -779,3 +779,51 @@ Delete a list: .. literalinclude:: projects.py :start-after: # board lists delete :end-before: # end board lists delete + + +File Uploads +============ + +Reference +--------- + +* v4 API: + + + :attr:`gitlab.v4.objects.Project.upload` + + :class:`gitlab.v4.objects.ProjectUpload` + +* v3 API: + + + :attr:`gitlab.v3.objects.Project.upload` + + :class:`gitlab.v3.objects.ProjectUpload` + +* Gitlab API: https://docs.gitlab.com/ce/api/projects.html#upload-a-file + +Examples +-------- + +Upload a file into a project using a filesystem path: + +.. literalinclude:: projects.py + :start-after: # project file upload by path + :end-before: # end project file upload by path + +Upload a file into a project without a filesystem path: + +.. literalinclude:: projects.py + :start-after: # project file upload with data + :end-before: # end project file upload with data + +Upload a file and comment on an issue using the uploaded file's +markdown: + +.. literalinclude:: projects.py + :start-after: # project file upload markdown + :end-before: # end project file upload markdown + +Upload a file and comment on an issue while using custom +markdown to reference the uploaded file: + +.. literalinclude:: projects.py + :start-after: # project file upload markdown custom + :end-before: # end project file upload markdown custom diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 2a81a25c6..93389ca74 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -628,7 +628,7 @@ def _build_url(self, path): return '%s%s' % (self._url, path) def http_request(self, verb, path, query_data={}, post_data={}, - streamed=False, **kwargs): + streamed=False, files=None, **kwargs): """Make an HTTP request to the Gitlab server. Args: @@ -658,6 +658,11 @@ def sanitized_url(url): params = query_data.copy() params.update(kwargs) opts = self._get_session_opts(content_type='application/json') + + # don't set the content-type header when uploading files + if files is not None: + del opts["headers"]["Content-type"] + verify = opts.pop('verify') timeout = opts.pop('timeout') @@ -668,7 +673,7 @@ def sanitized_url(url): # always agree with this decision (this is the case with a default # gitlab installation) req = requests.Request(verb, url, json=post_data, params=params, - **opts) + files=files, **opts) prepped = self.session.prepare_request(req) prepped.url = sanitized_url(prepped.url) result = self.session.send(prepped, stream=streamed, verify=verify, @@ -754,7 +759,7 @@ def http_list(self, path, query_data={}, as_list=None, **kwargs): # No pagination, generator requested return GitlabList(self, url, query_data, **kwargs) - def http_post(self, path, query_data={}, post_data={}, **kwargs): + def http_post(self, path, query_data={}, post_data={}, files=None, **kwargs): """Make a POST request to the Gitlab server. Args: @@ -774,7 +779,7 @@ def http_post(self, path, query_data={}, post_data={}, **kwargs): GitlabParsingError: If the json data could not be parsed """ result = self.http_request('post', path, query_data=query_data, - post_data=post_data, **kwargs) + post_data=post_data, files=files, **kwargs) try: if result.headers.get('Content-Type', None) == 'application/json': return result.json() diff --git a/gitlab/base.py b/gitlab/base.py index a9521eb1d..3531a1b6f 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -533,10 +533,34 @@ def __ne__(self, other): return not self.__eq__(other) -class RESTObject(object): +class InformationalObject(object): + """A basic object that holds values, more like a struct + than a class. + + Inheriting classes should define which attributes are relevant/important + when defining the class. E.g.: + + .. code-block:: python + + class ProjectUpload(InformationalObject): + _attr_names = ["alt", "markdown", "id", "url"] + """ + _attr_names = [] + _id_attr = "id" + _short_print_attr = "id" + + @property + def attributes(self): + res = {} + for attr_name in self._attr_names: + res[attr_name] = getattr(self, attr_name) + return res + + +class RESTObject(InformationalObject): """Represents an object built from server data. - It holds the attributes know from te server, and the updated attributes in + It holds the attributes know from the server, and the updated attributes in another. This allows smart updates, if the object allows it. You can redefine ``_id_attr`` in child classes to specify which attribute diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py index ae16cf7d7..9369fe384 100644 --- a/gitlab/v3/cli.py +++ b/gitlab/v3/cli.py @@ -68,7 +68,8 @@ 'unstar': {'required': ['id']}, 'archive': {'required': ['id']}, 'unarchive': {'required': ['id']}, - 'share': {'required': ['id', 'group-id', 'group-access']}}, + 'share': {'required': ['id', 'group-id', 'group-access']}, + 'upload': {'required': ['id', 'filename', 'filepath']}}, gitlab.v3.objects.User: { 'block': {'required': ['id']}, 'unblock': {'required': ['id']}, @@ -348,6 +349,19 @@ def do_user_getbyusername(self, cls, gl, what, args): except Exception as e: cli.die("Impossible to get user %s" % args['query'], e) + def do_project_upload(self, cls, gl, what, args): + try: + project = gl.projects.get(args["id"]) + except Exception as e: + cli.die("Could not load project '{!r}'".format(args["id"]), e) + + try: + res = project.upload(filename=args["filename"], filepath=args["filepath"]) + except Exception as e: + cli.die("Could not upload file into project", e) + + return res + def _populate_sub_parser_by_class(cls, sub_parser): for action_name in ['list', 'get', 'create', 'update', 'delete']: @@ -469,6 +483,7 @@ def run(gl, what, action, args, verbose, *fargs, **kwargs): cli.die("Unknown object: %s" % what) g_cli = GitlabCLI() + method = None what = what.replace('-', '_') action = action.lower().replace('-', '') diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index b28f3139a..0237ee2f4 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -66,55 +66,6 @@ def compound_metrics(self, **kwargs): return self._simple_get('/sidekiq/compound_metrics', **kwargs) -class ProjectUploadable(object): - """A mixin for objects that allow files to be uploaded/attached. - """ - - description_attr = "description" - """The attribute of the object that newly uploaded files' - markdown should be appended to. - """ - - project_id_attr = "project_id" - """The attribute that specifies the attribute that contains - the project id into which files may be uploaded. - """ - - # see #56 - add file uploading/attachment features - def attach_file(self, filename, filedata, **kwargs): - """Attach a file to the issue - - Args: - filename (str): The filename of the file being uploaded. - filedata (str): The raw data of the file being uploaded. - Raises: - GitlabConnectionError: If the server cannot be reached. - """ - project_id = getattr(self, self.project_id_attr, None) - if project_id is None: - raise GitlabAttachFileError("{}'s project id could not be determined (tried using {!r})".format( - self, - self.project_id_attr, - )) - - project = self.gitlab.projects.get(project_id) - res = project.upload( - filename = filename, - filedata = filedata, - ) - - orig_desc = getattr(self, self.description_attr, "") - # files are "attached" to issues and comments by uploading the - # the file into the project, and including the returned - # markdown in the description - setattr(self, self.description_attr, orig_desc + "\n\n" + res["markdown"]) - - # XXX:TODO: Is this correct? any changes to the current object instance - # that have not yet been saved will be saved along with the - # file upload, which *may not* be what the user intended. - self.save() - - class UserEmail(GitlabObject): _url = '/users/%(user_id)s/emails' canUpdate = False @@ -950,7 +901,7 @@ class ProjectHookManager(BaseManager): obj_cls = ProjectHook -class ProjectIssueNote(GitlabObject, ProjectUploadable): +class ProjectIssueNote(GitlabObject): _url = '/projects/%(project_id)s/issues/%(issue_id)s/notes' _constructorTypes = {'author': 'User'} canDelete = False @@ -967,7 +918,7 @@ class ProjectIssueNoteManager(BaseManager): obj_cls = ProjectIssueNote -class ProjectIssue(GitlabObject, ProjectUploadable): +class ProjectIssue(GitlabObject): _url = '/projects/%(project_id)s/issues/' _constructorTypes = {'author': 'User', 'assignee': 'User', 'milestone': 'ProjectMilestone'} @@ -1819,6 +1770,33 @@ class ProjectRunnerManager(BaseManager): obj_cls = ProjectRunner +class ProjectUpload(GitlabObject): + shortPrintAttr = "url" + + def __init__(self, alt, url, markdown): + """Create a new ProfileFileUpload instance that + holds the ``alt`` (uploaded filename without the extension), + ``url``, and ``markdown`` data about the file upload. + + Args: + alt (str): The alt of the upload + url (str): The url of to the uploaded file + markdown (str): The markdown text that creates a link to the uploaded file + """ + self.alt = alt + self.url = url + self.markdown = markdown + + # url should be in this form: /uploads/ID/filename.txt + self.id = url.replace("/uploads/", "").split("/")[0] + + def __str__(self): + """Return the markdown representation of the uploaded + file. + """ + return self.markdown + + class Project(GitlabObject): _url = '/projects' _constructorTypes = {'owner': 'User', 'namespace': 'Group'} @@ -2155,27 +2133,38 @@ def trigger_build(self, ref, token, variables={}, **kwargs): raise_error_from_response(r, GitlabCreateError, 201) # see #56 - add file attachment features - def upload(self, filename, filedata, **kwargs): - """Upload a file into the project. This will return the raw response - from the gitlab API in the form: - - { - "alt": "dk", - "url": "/uploads/66dbcd21ec5d24ed6ea225176098d52b/dk.png", - "markdown": "![dk](/uploads/66dbcd21ec5d24ed6ea225176098d52b/dk.png)" - } - - See https://github.com/gitlabhq/gitlabhq/blob/master/doc/api/projects.md#upload-a-file - for more information. + def upload(self, filename, filedata=None, filepath=None, **kwargs): + """Upload the specified file into the project. + + .. note:: + + Either ``filedata`` or ``filepath`` *MUST* be specified. Args: filename (str): The name of the file being uploaded filedata (bytes): The raw data of the file being uploaded + filepath (str): The path to a local file to upload (optional) Raises: GitlabConnectionError: If the server cannot be reached GitlabUploadError: If the file upload fails + GitlabUploadError: If ``filedata`` and ``filepath`` are not specified + GitlabUploadError: If both ``filedata`` and ``filepath`` are specified + + Returns: + ProjectUpload: A ``ProjectUpload`` instance containing + information about the uploaded file. """ + if filepath is None and filedata is None: + raise GitlabUploadError("No file contents or path specified") + + if filedata is not None and filepath is not None: + raise GitlabUploadError("File contents and file path specified") + + if filepath is not None: + with open(filepath, "rb") as f: + filedata = f.read() + url = ("/projects/%(id)s/uploads" % { "id": self.id, }) @@ -2185,7 +2174,13 @@ def upload(self, filename, filedata, **kwargs): ) # returns 201 status code (created) raise_error_from_response(r, GitlabUploadError, expected_code=201) - return r.json() + data = r.json() + + return ProjectUpload( + alt = data["alt"], + url = data["url"], + markdown = data["markdown"] + ) class Runner(GitlabObject): diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index e61ef2036..a4cdf932f 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -321,7 +321,7 @@ def get_dict(obj): else: print(obj) print('') - elif isinstance(ret_val, gitlab.base.RESTObject): + elif isinstance(ret_val, gitlab.base.InformationalObject): printer.display(get_dict(ret_val), verbose=verbose, obj=ret_val) elif isinstance(ret_val, six.string_types): print(ret_val) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 3b1eb9175..91893bd69 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1743,6 +1743,39 @@ class ProjectRunnerManager(NoUpdateMixin, RESTManager): _create_attrs = (('runner_id', ), tuple()) +class ProjectUpload(InformationalObject): + _attr_names = [ + "alt", + "url", + "markdown", + "id", + ] + _short_print_attr = "url" + + def __init__(self, alt, url, markdown): + """Create a new ProfileFileUpload instance that + holds the ``alt`` (uploaded filename without the extension), + ``url``, and ``markdown`` data about the file upload. + + Args: + alt (str): The alt of the upload + url (str): The url of to the uploaded file + markdown (str): The markdown text that creates a link to the uploaded file + """ + self.alt = alt + self.url = url + self.markdown = markdown + + # url should be in this form: /uploads/ID/filename.txt + self.id = url.replace("/uploads/", "").split("/")[0] + + def __str__(self): + """Return the markdown representation of the uploaded + file. + """ + return self.markdown + + class Project(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'owner': 'User', 'namespace': 'Group'} _short_print_attr = 'path' @@ -2060,6 +2093,55 @@ def trigger_pipeline(self, ref, token, variables={}, **kwargs): post_data.update(form) self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) + # see #56 - add file attachment features + @cli.register_custom_action('Project', ('filename', 'filepath')) + @exc.on_http_error(exc.GitlabUploadError) + def upload(self, filename, filedata=None, filepath=None, **kwargs): + """Upload the specified file into the project. + + .. note:: + + Either ``filedata`` or ``filepath`` *MUST* be specified. + + Args: + filename (str): The name of the file being uploaded + filedata (bytes): The raw data of the file being uploaded + filepath (str): The path to a local file to upload (optional) + + Raises: + GitlabConnectionError: If the server cannot be reached + GitlabUploadError: If the file upload fails + GitlabUploadError: If ``filedata`` and ``filepath`` are not specified + GitlabUploadError: If both ``filedata`` and ``filepath`` are specified + + Returns: + ProjectUpload: A ``ProjectUpload`` instance containing + information about the uploaded file. + """ + if filepath is None and filedata is None: + raise GitlabUploadError("No file contents or path specified") + + if filedata is not None and filepath is not None: + raise GitlabUploadError("File contents and file path specified") + + if filepath is not None: + with open(filepath, "rb") as f: + filedata = f.read() + + url = ('/projects/%(id)s/uploads' % { + 'id': self.id, + }) + file_info = { + 'file': (filename, filedata), + } + data = self.manager.gitlab.http_post(url, files=file_info) + + return ProjectUpload( + alt = data['alt'], + url = data['url'], + markdown = data['markdown'] + ) + class Runner(SaveMixin, ObjectDeleteMixin, RESTObject): pass diff --git a/tools/cli_test_v3.sh b/tools/cli_test_v3.sh index d71f4378b..ed433ceef 100644 --- a/tools/cli_test_v3.sh +++ b/tools/cli_test_v3.sh @@ -98,6 +98,10 @@ testcase "branch deletion" ' --name branch1 >/dev/null 2>&1 ' +testcase "project upload" ' + GITLAB project upload --id "$PROJECT_ID" --filename '$(basename $0)' --filepath '$0' +' + testcase "project deletion" ' GITLAB project delete --id "$PROJECT_ID" ' diff --git a/tools/cli_test_v4.sh b/tools/cli_test_v4.sh index 8399bd855..813d85b06 100644 --- a/tools/cli_test_v4.sh +++ b/tools/cli_test_v4.sh @@ -94,6 +94,10 @@ testcase "branch deletion" ' --name branch1 >/dev/null 2>&1 ' +testcase "project upload" ' + GITLAB project upload --id "$PROJECT_ID" --filename '$(basename $0)' --filepath '$0' +' + testcase "project deletion" ' GITLAB project delete --id "$PROJECT_ID" ' diff --git a/tools/python_test_v3.py b/tools/python_test_v3.py index a730f77fe..30275153b 100644 --- a/tools/python_test_v3.py +++ b/tools/python_test_v3.py @@ -1,4 +1,5 @@ import base64 +import re import time import gitlab @@ -194,6 +195,21 @@ archive2 = admin_project.repository_archive('master') assert(archive1 == archive2) +# project file uploads +filename = "test.txt" +file_contents = "testing contents" +uploaded_file = admin_project.upload(filename, file_contents) +assert(uploaded_file.id in uploaded_file.url) +assert(uploaded_file.alt == filename) +assert(uploaded_file.url.startswith("/uploads/")) +assert(uploaded_file.url.endswith("/" + filename)) +assert(uploaded_file.markdown == "[{}]({})".format( + uploaded_file.alt, + uploaded_file.url, +)) +# should default to markdown when passed to str() +assert(str(uploaded_file) == uploaded_file.markdown) + # deploy keys deploy_key = admin_project.keys.create({'title': 'foo@bar', 'key': DEPLOY_KEY}) project_keys = admin_project.keys.list() diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 8cc088644..bb5e836db 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -246,6 +246,21 @@ archive2 = admin_project.repository_archive('master') assert(archive1 == archive2) +# project file uploads +filename = "test.txt" +file_contents = "testing contents" +uploaded_file = admin_project.upload_file(filename, file_contents) +assert(uploaded_file.id in uploaded_file.url) +assert(uploaded_file.alt == filename) +assert(uploaded_file.url.startswith("/uploads/")) +assert(uploaded_file.url.endswith("/" + filename)) +assert(uploaded_file.markdown == "[{}]({})".format( + uploaded_file.alt, + uploaded_file.url, +)) +# should default to markdown when passed to str() +assert(str(uploaded_file) == uploaded_file.markdown) + # environments admin_project.environments.create({'name': 'env1', 'external_url': 'http://fake.env/whatever'}) From e6bf188b9f09dced24c036d992489df44efce3dc Mon Sep 17 00:00:00 2001 From: "James (d0c_s4vage) Johnson" Date: Tue, 22 Aug 2017 16:57:34 -0500 Subject: [PATCH 3/7] unit-test fix and pep8-compatability changes see #56 --- gitlab/__init__.py | 9 ++++++--- gitlab/v3/cli.py | 3 ++- gitlab/v3/objects.py | 17 ++++++++++------- gitlab/v4/objects.py | 15 +++++++++------ tools/python_test_v4.py | 2 +- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 93389ca74..533848616 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -396,11 +396,13 @@ def _raw_list(self, path_, cls, **kwargs): return results - def _raw_post(self, path_, data=None, content_type=None, files=None, **kwargs): + def _raw_post(self, path_, data=None, content_type=None, + files=None, **kwargs): url = '%s%s' % (self._url, path_) opts = self._get_session_opts(content_type) try: - return self.session.post(url, params=kwargs, data=data, files=files, **opts) + return self.session.post(url, params=kwargs, data=data, + files=files, **opts) except Exception as e: raise GitlabConnectionError( "Can't connect to GitLab server (%s)" % e) @@ -759,7 +761,8 @@ def http_list(self, path, query_data={}, as_list=None, **kwargs): # No pagination, generator requested return GitlabList(self, url, query_data, **kwargs) - def http_post(self, path, query_data={}, post_data={}, files=None, **kwargs): + def http_post(self, path, query_data={}, post_data={}, files=None, + **kwargs): """Make a POST request to the Gitlab server. Args: diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py index 9369fe384..73beb808d 100644 --- a/gitlab/v3/cli.py +++ b/gitlab/v3/cli.py @@ -356,7 +356,8 @@ def do_project_upload(self, cls, gl, what, args): cli.die("Could not load project '{!r}'".format(args["id"]), e) try: - res = project.upload(filename=args["filename"], filepath=args["filepath"]) + res = project.upload(filename=args["filename"], + filepath=args["filepath"]) except Exception as e: cli.die("Could not upload file into project", e) diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 0237ee2f4..22f98c34a 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -1781,7 +1781,8 @@ def __init__(self, alt, url, markdown): Args: alt (str): The alt of the upload url (str): The url of to the uploaded file - markdown (str): The markdown text that creates a link to the uploaded file + markdown (str): The markdown text that creates a link to the + uploaded file """ self.alt = alt self.url = url @@ -2148,8 +2149,10 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): Raises: GitlabConnectionError: If the server cannot be reached GitlabUploadError: If the file upload fails - GitlabUploadError: If ``filedata`` and ``filepath`` are not specified - GitlabUploadError: If both ``filedata`` and ``filepath`` are specified + GitlabUploadError: If ``filedata`` and ``filepath`` are not + specified + GitlabUploadError: If both ``filedata`` and ``filepath`` are + specified Returns: ProjectUpload: A ``ProjectUpload`` instance containing @@ -2170,16 +2173,16 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): }) r = self.gitlab._raw_post( url, - files = {"file": (filename, filedata)}, + files={"file": (filename, filedata)}, ) # returns 201 status code (created) raise_error_from_response(r, GitlabUploadError, expected_code=201) data = r.json() return ProjectUpload( - alt = data["alt"], - url = data["url"], - markdown = data["markdown"] + alt=data["alt"], + url=data["url"], + markdown=data["markdown"] ) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index 91893bd69..ccf48dfd4 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1760,7 +1760,8 @@ def __init__(self, alt, url, markdown): Args: alt (str): The alt of the upload url (str): The url of to the uploaded file - markdown (str): The markdown text that creates a link to the uploaded file + markdown (str): The markdown text that creates a link to + the uploaded file """ self.alt = alt self.url = url @@ -2111,8 +2112,10 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): Raises: GitlabConnectionError: If the server cannot be reached GitlabUploadError: If the file upload fails - GitlabUploadError: If ``filedata`` and ``filepath`` are not specified - GitlabUploadError: If both ``filedata`` and ``filepath`` are specified + GitlabUploadError: If ``filedata`` and ``filepath`` are not + specified + GitlabUploadError: If both ``filedata`` and ``filepath`` are + specified Returns: ProjectUpload: A ``ProjectUpload`` instance containing @@ -2137,9 +2140,9 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): data = self.manager.gitlab.http_post(url, files=file_info) return ProjectUpload( - alt = data['alt'], - url = data['url'], - markdown = data['markdown'] + alt=data['alt'], + url=data['url'], + markdown=data['markdown'] ) diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index bb5e836db..4dfc05477 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -249,7 +249,7 @@ # project file uploads filename = "test.txt" file_contents = "testing contents" -uploaded_file = admin_project.upload_file(filename, file_contents) +uploaded_file = admin_project.upload(filename, file_contents) assert(uploaded_file.id in uploaded_file.url) assert(uploaded_file.alt == filename) assert(uploaded_file.url.startswith("/uploads/")) From ea5676e995c2247fac6d4dd4361d2f342626eeec Mon Sep 17 00:00:00 2001 From: "James (d0c_s4vage) Johnson" Date: Tue, 22 Aug 2017 17:21:03 -0500 Subject: [PATCH 4/7] pep8 issues taken care of now see #56 --- gitlab/base.py | 7 +++---- gitlab/v3/objects.py | 8 ++------ gitlab/v4/objects.py | 8 ++------ 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/gitlab/base.py b/gitlab/base.py index 3531a1b6f..f400a23c8 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -534,11 +534,10 @@ def __ne__(self, other): class InformationalObject(object): - """A basic object that holds values, more like a struct - than a class. + """A basic object that holds values, more like a struct than a class. - Inheriting classes should define which attributes are relevant/important - when defining the class. E.g.: + Inheriting classes should define which attributes are + relevant/important when defining the class. E.g.: .. code-block:: python diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 22f98c34a..5e97eb109 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -1774,9 +1774,7 @@ class ProjectUpload(GitlabObject): shortPrintAttr = "url" def __init__(self, alt, url, markdown): - """Create a new ProfileFileUpload instance that - holds the ``alt`` (uploaded filename without the extension), - ``url``, and ``markdown`` data about the file upload. + """Create a new ProjectUpload instance. Args: alt (str): The alt of the upload @@ -1792,9 +1790,7 @@ def __init__(self, alt, url, markdown): self.id = url.replace("/uploads/", "").split("/")[0] def __str__(self): - """Return the markdown representation of the uploaded - file. - """ + """Return the markdown representation of the uploaded file.""" return self.markdown diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index ccf48dfd4..b77372215 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1753,9 +1753,7 @@ class ProjectUpload(InformationalObject): _short_print_attr = "url" def __init__(self, alt, url, markdown): - """Create a new ProfileFileUpload instance that - holds the ``alt`` (uploaded filename without the extension), - ``url``, and ``markdown`` data about the file upload. + """Create a new ProjectUpload instance. Args: alt (str): The alt of the upload @@ -1771,9 +1769,7 @@ def __init__(self, alt, url, markdown): self.id = url.replace("/uploads/", "").split("/")[0] def __str__(self): - """Return the markdown representation of the uploaded - file. - """ + """Return the markdown representation of the uploaded file.""" return self.markdown From f63b0700c9fd0ae37a65e3099192fbf6cdfdca98 Mon Sep 17 00:00:00 2001 From: "James (d0c_s4vage) Johnson" Date: Mon, 11 Sep 2017 06:19:34 -0500 Subject: [PATCH 5/7] Reworked the upload return value and updated docs and tests see #56 --- docs/gl_objects/projects.py | 4 ++-- gitlab/base.py | 25 +------------------- gitlab/v3/cli.py | 3 +++ gitlab/v3/objects.py | 40 ++++++++------------------------ gitlab/v4/cli.py | 4 +++- gitlab/v4/objects.py | 46 ++++++++----------------------------- tools/python_test_v3.py | 12 ++++------ tools/python_test_v4.py | 12 ++++------ 8 files changed, 37 insertions(+), 109 deletions(-) diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py index 0f0a01c5c..8fbcf2b88 100644 --- a/docs/gl_objects/projects.py +++ b/docs/gl_objects/projects.py @@ -383,7 +383,7 @@ uploaded_file = project.upload_file("filename.txt", filedata="data") issue = project.issues.get(issue_id) issue.notes.create({ - "body": "See the attached file: {}".format(uploaded_file) + "body": "See the attached file: {}".format(uploaded_file["markdown"]) }) # project file upload markdown @@ -391,6 +391,6 @@ uploaded_file = project.upload_file("filename.txt", filedata="data") issue = project.issues.get(issue_id) issue.notes.create({ - "body": "See the [attached file]({})".format(uploaded_file.url) + "body": "See the [attached file]({})".format(uploaded_file["url"]) }) # project file upload markdown diff --git a/gitlab/base.py b/gitlab/base.py index f400a23c8..01f690364 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -533,30 +533,7 @@ def __ne__(self, other): return not self.__eq__(other) -class InformationalObject(object): - """A basic object that holds values, more like a struct than a class. - - Inheriting classes should define which attributes are - relevant/important when defining the class. E.g.: - - .. code-block:: python - - class ProjectUpload(InformationalObject): - _attr_names = ["alt", "markdown", "id", "url"] - """ - _attr_names = [] - _id_attr = "id" - _short_print_attr = "id" - - @property - def attributes(self): - res = {} - for attr_name in self._attr_names: - res[attr_name] = getattr(self, attr_name) - return res - - -class RESTObject(InformationalObject): +class RESTObject(object): """Represents an object built from server data. It holds the attributes know from the server, and the updated attributes in diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py index 73beb808d..bea0e8531 100644 --- a/gitlab/v3/cli.py +++ b/gitlab/v3/cli.py @@ -507,6 +507,9 @@ def run(gl, what, action, args, verbose, *fargs, **kwargs): print("") else: print(o) + elif isinstance(ret_val, dict): + for k,v in six.iteritems(ret_val): + print("{} = {}".format(k, v)) elif isinstance(ret_val, gitlab.base.GitlabObject): ret_val.display(verbose) elif isinstance(ret_val, six.string_types): diff --git a/gitlab/v3/objects.py b/gitlab/v3/objects.py index 5e97eb109..338d2190c 100644 --- a/gitlab/v3/objects.py +++ b/gitlab/v3/objects.py @@ -1770,30 +1770,6 @@ class ProjectRunnerManager(BaseManager): obj_cls = ProjectRunner -class ProjectUpload(GitlabObject): - shortPrintAttr = "url" - - def __init__(self, alt, url, markdown): - """Create a new ProjectUpload instance. - - Args: - alt (str): The alt of the upload - url (str): The url of to the uploaded file - markdown (str): The markdown text that creates a link to the - uploaded file - """ - self.alt = alt - self.url = url - self.markdown = markdown - - # url should be in this form: /uploads/ID/filename.txt - self.id = url.replace("/uploads/", "").split("/")[0] - - def __str__(self): - """Return the markdown representation of the uploaded file.""" - return self.markdown - - class Project(GitlabObject): _url = '/projects' _constructorTypes = {'owner': 'User', 'namespace': 'Group'} @@ -2151,8 +2127,10 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): specified Returns: - ProjectUpload: A ``ProjectUpload`` instance containing - information about the uploaded file. + dict: A ``dict`` with the keys: + * ``alt`` - The alternate text for the upload + * ``url`` - The direct url to the uploaded file + * ``markdown`` - Markdown for the uploaded file """ if filepath is None and filedata is None: raise GitlabUploadError("No file contents or path specified") @@ -2175,11 +2153,11 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): raise_error_from_response(r, GitlabUploadError, expected_code=201) data = r.json() - return ProjectUpload( - alt=data["alt"], - url=data["url"], - markdown=data["markdown"] - ) + return { + "alt": data['alt'], + "url": data['url'], + "markdown": data['markdown'] + } class Runner(GitlabObject): diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index a4cdf932f..53e40e851 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -321,7 +321,9 @@ def get_dict(obj): else: print(obj) print('') - elif isinstance(ret_val, gitlab.base.InformationalObject): + elif isinstance(ret_val, dict): + printer.display(ret_val, verbose=verbose, obj=ret_val) + elif isinstance(ret_val, gitlab.base.RESTObject): printer.display(get_dict(ret_val), verbose=verbose, obj=ret_val) elif isinstance(ret_val, six.string_types): print(ret_val) diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py index b77372215..f1e9e4ea1 100644 --- a/gitlab/v4/objects.py +++ b/gitlab/v4/objects.py @@ -1743,36 +1743,6 @@ class ProjectRunnerManager(NoUpdateMixin, RESTManager): _create_attrs = (('runner_id', ), tuple()) -class ProjectUpload(InformationalObject): - _attr_names = [ - "alt", - "url", - "markdown", - "id", - ] - _short_print_attr = "url" - - def __init__(self, alt, url, markdown): - """Create a new ProjectUpload instance. - - Args: - alt (str): The alt of the upload - url (str): The url of to the uploaded file - markdown (str): The markdown text that creates a link to - the uploaded file - """ - self.alt = alt - self.url = url - self.markdown = markdown - - # url should be in this form: /uploads/ID/filename.txt - self.id = url.replace("/uploads/", "").split("/")[0] - - def __str__(self): - """Return the markdown representation of the uploaded file.""" - return self.markdown - - class Project(SaveMixin, ObjectDeleteMixin, RESTObject): _constructor_types = {'owner': 'User', 'namespace': 'Group'} _short_print_attr = 'path' @@ -2114,8 +2084,10 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): specified Returns: - ProjectUpload: A ``ProjectUpload`` instance containing - information about the uploaded file. + dict: A ``dict`` with the keys: + * ``alt`` - The alternate text for the upload + * ``url`` - The direct url to the uploaded file + * ``markdown`` - Markdown for the uploaded file """ if filepath is None and filedata is None: raise GitlabUploadError("No file contents or path specified") @@ -2135,11 +2107,11 @@ def upload(self, filename, filedata=None, filepath=None, **kwargs): } data = self.manager.gitlab.http_post(url, files=file_info) - return ProjectUpload( - alt=data['alt'], - url=data['url'], - markdown=data['markdown'] - ) + return { + "alt": data['alt'], + "url": data['url'], + "markdown": data['markdown'] + } class Runner(SaveMixin, ObjectDeleteMixin, RESTObject): diff --git a/tools/python_test_v3.py b/tools/python_test_v3.py index 30275153b..2f815164e 100644 --- a/tools/python_test_v3.py +++ b/tools/python_test_v3.py @@ -199,16 +199,14 @@ filename = "test.txt" file_contents = "testing contents" uploaded_file = admin_project.upload(filename, file_contents) -assert(uploaded_file.id in uploaded_file.url) -assert(uploaded_file.alt == filename) -assert(uploaded_file.url.startswith("/uploads/")) -assert(uploaded_file.url.endswith("/" + filename)) -assert(uploaded_file.markdown == "[{}]({})".format( +assert(uploaded_file["id"] in uploaded_file.url) +assert(uploaded_file["alt"] == filename) +assert(uploaded_file["url"].startswith("/uploads/")) +assert(uploaded_file["url"].endswith("/" + filename)) +assert(uploaded_file["markdown"] == "[{}]({})".format( uploaded_file.alt, uploaded_file.url, )) -# should default to markdown when passed to str() -assert(str(uploaded_file) == uploaded_file.markdown) # deploy keys deploy_key = admin_project.keys.create({'title': 'foo@bar', 'key': DEPLOY_KEY}) diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 4dfc05477..46775e3b0 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -250,16 +250,14 @@ filename = "test.txt" file_contents = "testing contents" uploaded_file = admin_project.upload(filename, file_contents) -assert(uploaded_file.id in uploaded_file.url) -assert(uploaded_file.alt == filename) -assert(uploaded_file.url.startswith("/uploads/")) -assert(uploaded_file.url.endswith("/" + filename)) -assert(uploaded_file.markdown == "[{}]({})".format( +assert(uploaded_file["id"] in uploaded_file.url) +assert(uploaded_file["alt"] == filename) +assert(uploaded_file["url"].startswith("/uploads/")) +assert(uploaded_file["url"].endswith("/" + filename)) +assert(uploaded_file["markdown"] == "[{}]({})".format( uploaded_file.alt, uploaded_file.url, )) -# should default to markdown when passed to str() -assert(str(uploaded_file) == uploaded_file.markdown) # environments admin_project.environments.create({'name': 'env1', 'external_url': From cd1da4af56f325cce5b90a20bd75bcf8cf2e0752 Mon Sep 17 00:00:00 2001 From: "James (d0c_s4vage) Johnson" Date: Mon, 11 Sep 2017 06:30:56 -0500 Subject: [PATCH 6/7] Forgot to update a few lines in the py functional tests Also fixed PEP8 error in v3/cli.py see #56 --- gitlab/v3/cli.py | 2 +- tools/python_test_v3.py | 4 ++-- tools/python_test_v4.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py index bea0e8531..a8e3a5fae 100644 --- a/gitlab/v3/cli.py +++ b/gitlab/v3/cli.py @@ -508,7 +508,7 @@ def run(gl, what, action, args, verbose, *fargs, **kwargs): else: print(o) elif isinstance(ret_val, dict): - for k,v in six.iteritems(ret_val): + for k, v in six.iteritems(ret_val): print("{} = {}".format(k, v)) elif isinstance(ret_val, gitlab.base.GitlabObject): ret_val.display(verbose) diff --git a/tools/python_test_v3.py b/tools/python_test_v3.py index 2f815164e..ed2dd4e58 100644 --- a/tools/python_test_v3.py +++ b/tools/python_test_v3.py @@ -204,8 +204,8 @@ assert(uploaded_file["url"].startswith("/uploads/")) assert(uploaded_file["url"].endswith("/" + filename)) assert(uploaded_file["markdown"] == "[{}]({})".format( - uploaded_file.alt, - uploaded_file.url, + uploaded_file["alt"], + uploaded_file["url"], )) # deploy keys diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 46775e3b0..99c860857 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -255,8 +255,8 @@ assert(uploaded_file["url"].startswith("/uploads/")) assert(uploaded_file["url"].endswith("/" + filename)) assert(uploaded_file["markdown"] == "[{}]({})".format( - uploaded_file.alt, - uploaded_file.url, + uploaded_file["alt"], + uploaded_file["url"], )) # environments From 3853d4f15c062aec82c4198fb5a2d150278b8ea8 Mon Sep 17 00:00:00 2001 From: "James (d0c_s4vage) Johnson" Date: Mon, 11 Sep 2017 13:52:43 -0500 Subject: [PATCH 7/7] Removed non-existent key "id" in file upload tests see #56 --- tools/python_test_v3.py | 1 - tools/python_test_v4.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tools/python_test_v3.py b/tools/python_test_v3.py index ed2dd4e58..00faccc87 100644 --- a/tools/python_test_v3.py +++ b/tools/python_test_v3.py @@ -199,7 +199,6 @@ filename = "test.txt" file_contents = "testing contents" uploaded_file = admin_project.upload(filename, file_contents) -assert(uploaded_file["id"] in uploaded_file.url) assert(uploaded_file["alt"] == filename) assert(uploaded_file["url"].startswith("/uploads/")) assert(uploaded_file["url"].endswith("/" + filename)) diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py index 99c860857..975afe47e 100644 --- a/tools/python_test_v4.py +++ b/tools/python_test_v4.py @@ -250,7 +250,6 @@ filename = "test.txt" file_contents = "testing contents" uploaded_file = admin_project.upload(filename, file_contents) -assert(uploaded_file["id"] in uploaded_file.url) assert(uploaded_file["alt"] == filename) assert(uploaded_file["url"].startswith("/uploads/")) assert(uploaded_file["url"].endswith("/" + filename))