summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/gl_objects/projects.py26
-rw-r--r--docs/gl_objects/projects.rst48
-rw-r--r--gitlab/__init__.py20
-rw-r--r--gitlab/base.py2
-rw-r--r--gitlab/exceptions.py8
-rw-r--r--gitlab/v3/cli.py21
-rw-r--r--gitlab/v3/objects.py63
-rw-r--r--gitlab/v4/cli.py2
-rw-r--r--gitlab/v4/objects.py53
-rw-r--r--tools/cli_test_v3.sh4
-rw-r--r--tools/cli_test_v4.sh4
-rw-r--r--tools/python_test_v3.py13
-rw-r--r--tools/python_test_v4.py12
13 files changed, 268 insertions, 8 deletions
diff --git a/docs/gl_objects/projects.py b/docs/gl_objects/projects.py
index 131f43c..8fbcf2b 100644
--- a/docs/gl_objects/projects.py
+++ b/docs/gl_objects/projects.py
@@ -368,3 +368,29 @@ b_list.save()
# 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["markdown"])
+})
+# 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 4a8a0ad..b6cf311 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 0768abb..4a56175 100644
--- a/gitlab/__init__.py
+++ b/gitlab/__init__.py
@@ -396,11 +396,13 @@ class Gitlab(object):
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)
@@ -628,7 +630,7 @@ class Gitlab(object):
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 +660,11 @@ class Gitlab(object):
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 +675,7 @@ class Gitlab(object):
# 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,
@@ -756,7 +763,8 @@ class Gitlab(object):
# 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:
@@ -776,7 +784,7 @@ class Gitlab(object):
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 a9521eb..01f6903 100644
--- a/gitlab/base.py
+++ b/gitlab/base.py
@@ -536,7 +536,7 @@ class GitlabObject(object):
class RESTObject(object):
"""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/exceptions.py b/gitlab/exceptions.py
index 6aad810..a100395 100644
--- a/gitlab/exceptions.py
+++ b/gitlab/exceptions.py
@@ -173,6 +173,14 @@ class GitlabTimeTrackingError(GitlabOperationError):
pass
+class GitlabUploadError(GitlabOperationError):
+ pass
+
+
+class GitlabAttachFileError(GitlabOperationError):
+ pass
+
+
class GitlabCherryPickError(GitlabOperationError):
pass
diff --git a/gitlab/v3/cli.py b/gitlab/v3/cli.py
index ae16cf7..a8e3a5f 100644
--- a/gitlab/v3/cli.py
+++ b/gitlab/v3/cli.py
@@ -68,7 +68,8 @@ EXTRA_ACTIONS = {
'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,20 @@ class GitlabCLI(object):
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 +484,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('-', '')
@@ -491,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 94c3873..338d219 100644
--- a/gitlab/v3/objects.py
+++ b/gitlab/v3/objects.py
@@ -909,6 +909,10 @@ 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
@@ -933,6 +937,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.
@@ -1057,6 +1065,7 @@ class ProjectIssueManager(BaseManager):
class ProjectMember(GitlabObject):
_url = '/projects/%(project_id)s/members'
+
requiredUrlAttrs = ['project_id']
requiredCreateAttrs = ['access_level', 'user_id']
optionalCreateAttrs = ['expires_at']
@@ -2096,6 +2105,60 @@ class Project(GitlabObject):
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=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:
+ 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")
+
+ 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,
+ })
+ r = self.gitlab._raw_post(
+ url,
+ files={"file": (filename, filedata)},
+ )
+ # returns 201 status code (created)
+ raise_error_from_response(r, GitlabUploadError, expected_code=201)
+ data = r.json()
+
+ return {
+ "alt": data['alt'],
+ "url": data['url'],
+ "markdown": data['markdown']
+ }
+
class Runner(GitlabObject):
_url = '/runners'
diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py
index 637adfc..6e664b3 100644
--- a/gitlab/v4/cli.py
+++ b/gitlab/v4/cli.py
@@ -324,6 +324,8 @@ def run(gl, what, action, args, verbose, output, fields):
else:
print(obj)
print('')
+ 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):
diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py
index 353f854..f1ec007 100644
--- a/gitlab/v4/objects.py
+++ b/gitlab/v4/objects.py
@@ -2071,6 +2071,59 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject):
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:
+ 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")
+
+ 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 {
+ "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 d71f437..ed433ce 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 8399bd8..813d85b 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 a730f77..00faccc 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,18 @@ archive1 = admin_project.repository_archive()
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["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"],
+))
+
# 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 2113830..386b59b 100644
--- a/tools/python_test_v4.py
+++ b/tools/python_test_v4.py
@@ -258,6 +258,18 @@ archive1 = admin_project.repository_archive()
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["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"],
+))
+
# environments
admin_project.environments.create({'name': 'env1', 'external_url':
'http://fake.env/whatever'})