Skip to content

Commit

Permalink
Merge pull request #89 from rbw0/branch-0.7.0
Browse files Browse the repository at this point in the history
Branch 0.7.0
  • Loading branch information
rbw authored Mar 30, 2018
2 parents e3f1921 + 8562a4f commit 9bc7230
Show file tree
Hide file tree
Showing 12 changed files with 321 additions and 32 deletions.
7 changes: 7 additions & 0 deletions docs/api/attachment.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Attachment
==========

.. automodule:: pysnow.attachment
.. autoclass:: Attachment
:members:

29 changes: 8 additions & 21 deletions docs/full_examples/attachments.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
Attaching a file
================
Attaching files
===============

Shows how to upload a binary file specified in the request body, providing information about the attachment using the `pysnow.ParamsBuilder` API exposed in `Resource.parameters`.
Shows how to upload a file using `Resource.attachments`.

.. note::
The attachment API (/api/now/attachment/file), as with all ServiceNow APIs that doesn't conform with the standard REST principles, requires you to use :meth:`Client.resource.request` and create a custom request.
Check out the `Attachment` API documentation for more info!


.. code-block:: python
Expand All @@ -15,21 +14,9 @@ Shows how to upload a binary file specified in the request body, providing infor
c = pysnow.Client(instance='myinstance', user='myusername', password='mypassword')
# Create a resource
attachment = c.resource(api_path='/attachment/file')
incidents = c.resource(api_path='/table/incident')
# Provide the required information about the attachment
attachment.parameters.add_custom({
'table_name': 'incident',
'table_sys_id': '<incident_sys_id>',
'file_name': 'attachment.txt'
})
# Set the payload
data = open('/tmp/attachment.txt', 'rb').read()
# Override the content-type header
headers = { "Content-Type": "text/plain" }
# Fire off the request
attachment.request(method='POST', data=data, headers=headers)
# Uploads file '/tmp/attachment.txt' to the provided incident
incidents.attachments.upload(sys_id='9b9dd196dbc91f005ab1f58dbf96192b',
file_path='/tmp/attachment.txt')
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Library written in Python that makes interacting with the ServiceNow REST API mu
api/client
api/oauth_client
api/query_builder
api/attachment
api/resource
api/params_builder
api/response
Expand Down
2 changes: 1 addition & 1 deletion pysnow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .params_builder import ParamsBuilder

__author__ = "Robert Wikman <[email protected]>"
__version__ = "0.6.9"
__version__ = "0.7.0"

# Set default logging handler to avoid "No handler found" warnings.
import logging
Expand Down
74 changes: 74 additions & 0 deletions pysnow/attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-

import os
from pysnow.exceptions import InvalidUsage


class Attachment(object):
"""Attachment management
:param resource: Table API resource to manage attachments for
:param table_name: Name of the table to use in the attachment API
"""

def __init__(self, resource, table_name):
self.resource = resource
self.table_name = table_name

def get(self, sys_id=None, limit=100):
"""Returns a list of attachments
:param sys_id: record sys_id to list attachments for
:param limit: override the default limit of 100
:return: list of attachments
"""

if sys_id:
return self.resource.get(query={'table_sys_id': sys_id, 'table_name': self.table_name}).all()

return self.resource.get(query={'table_name': self.table_name}, limit=limit).all()

def upload(self, sys_id, file_path, name=None, multipart=False):
"""Attaches a new file to the provided record
:param sys_id: the sys_id of the record to attach the file to
:param file_path: local absolute path of the file to upload
:param name: custom name for the uploaded file (instead of basename)
:param multipart: whether or not to use multipart
:return: the inserted record
"""

if not isinstance(multipart, bool):
raise InvalidUsage('Multipart must be of type bool')

resource = self.resource

if name is None:
name = os.path.basename(file_path)

resource.parameters.add_custom({
'table_name': self.table_name,
'table_sys_id': sys_id,
'file_name': name
})

data = open(file_path, 'rb').read()
headers = {}

if multipart:
headers["Content-Type"] = "multipart/form-data"
path_append = '/upload'
else:
headers["Content-Type"] = "text/plain"
path_append = '/file'

return resource.request(method='POST', data=data, headers=headers, path_append=path_append).one()

def delete(self, sys_id):
"""Deletes the provided attachment record
:param sys_id: attachment sys_id
:return: delete result
"""

return self.resource.delete(query={'sys_id': sys_id})
10 changes: 6 additions & 4 deletions pysnow/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,13 @@ def set_token(self, token):
self.token = None
return

expected_keys = set(("token_type", "refresh_token", "access_token", "scope", "expires_in", "expires_at"))
if not isinstance(token, dict) or not expected_keys <= set(token):
raise InvalidUsage("Token should contain a dictionary obtained using fetch_token()")
expected_keys = ['token_type', 'refresh_token', 'access_token', 'scope', 'expires_in', 'expires_at']
if not isinstance(token, dict) or not set(token) >= set(expected_keys):
raise InvalidUsage("Expected a token dictionary containing the following keys: {0}"
.format(expected_keys))

self.token = token
# Set sanitized token
self.token = dict((k, v) for k, v in token.items() if k in expected_keys)

def _legacy_request(self, *args, **kwargs):
"""Makes sure token has been set, then calls parent to create a new :class:`pysnow.LegacyRequest` object
Expand Down
43 changes: 39 additions & 4 deletions pysnow/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import logging

from copy import deepcopy
from copy import copy

from .request import SnowRequest
from .attachment import Attachment
from .url_builder import URLBuilder
from .exceptions import InvalidUsage

logger = logging.getLogger('pysnow')

Expand Down Expand Up @@ -33,7 +35,7 @@ def __init__(self, base_url=None, base_path=None, api_path=None, parameters=None
# @TODO - Remove this alias in a future release
self.custom = self.request

self.parameters = deepcopy(parameters)
self.parameters = copy(parameters)

logger.debug('(RESOURCE_ADD) Object: %s, chunk_size: %d' % (self, kwargs.get('chunk_size')))

Expand All @@ -42,16 +44,49 @@ def __repr__(self):

@property
def path(self):
"""Get current path relative to base URL
:return: resource path
"""

return "%s" % self._base_path + self._api_path

@property
def attachments(self):
"""Clones self, performs some path modifications and passes along to :class:`Attachment`
:return: Attachment object
"""

resource = copy(self)
resource._url_builder = URLBuilder(self._base_url, self._base_path, '/attachment')

path = self._api_path.strip('/').split('/')

if path[0] != 'table':
raise InvalidUsage('The attachment API can only be used with the table API')

return Attachment(resource, path[1])

@property
def _request(self):
parameters = deepcopy(self.parameters)
"""Request wrapper
:return: SnowRequest object
"""

parameters = copy(self.parameters)

return SnowRequest(url_builder=self._url_builder, parameters=parameters, parent=self, **self.kwargs)

def get_record_link(self, sys_id):
return "%s/%s" % (self._url_builder.full_path, sys_id)
"""Provides full URL to the provided sys_id
:param sys_id: sys_id to generate URL for
:return: full sys_id URL
"""

return "%s/%s" % (self._url_builder.get_url(), sys_id)

def get(self, query, limit=None, offset=None, fields=list()):
"""Queries the API resource
Expand Down
Empty file added tests/data/attachment.txt
Empty file.
147 changes: 147 additions & 0 deletions tests/test_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
import unittest
import json
import httpretty
import pysnow

from pysnow.exceptions import InvalidUsage


def get_serialized_result(dict_mock):
return json.dumps({'result': dict_mock})


attachment_path = 'tests/data/attachment.txt'
mock_sys_id = '98ace1a537ea2a00cf5c9c9953990e19'

attachments_resource = [
{
'sys_id': mock_sys_id,
'size_bytes': '512',
'file_name': 'test1.txt'
},
{
'sys_id': mock_sys_id,
'size_bytes': '1024',
'file_name': 'test2.txt'
},
{
'sys_id': mock_sys_id,
'size_bytes': '2048',
'file_name': 'test3.txt'
}
]

attachments_resource_sys_id = [
{
'sys_id': 'mock_sys_id1',
'size_bytes': '512',
'file_name': 'test1.txt'
},
{
'sys_id': 'mock_sys_id2',
'size_bytes': '1024',
'file_name': 'test2.txt'
}
]

attachment = {
'sys_id': mock_sys_id,
'size_bytes': '512',
'file_name': 'test1.txt'
}

delete_status = {'status': 'record deleted'}


class TestAttachment(unittest.TestCase):
def setUp(self):
client = pysnow.Client(instance='test', user='test', password='test')
r = self.resource = client.resource(api_path='/table/incident')
a = self.attachment_base_url = r._base_url + r._base_path + '/attachment'
self.attachment_url_binary = a + '/file'
self.attachment_url_multipart = a + '/upload'
self.attachment_url_sys_id = a + '/' + mock_sys_id

@httpretty.activate
def test_get_resource_all(self):
"""Getting metadata for multiple attachments within a resource should work"""

httpretty.register_uri(httpretty.GET,
self.attachment_base_url,
body=get_serialized_result(attachments_resource),
status=200,
content_type="application/json")

result = self.resource.attachments.get()
self.assertEqual(attachments_resource, list(result))

@httpretty.activate
def test_get_all_by_id(self):
"""Getting attachment metadata for a specific record by sys_id should work"""

httpretty.register_uri(httpretty.GET,
self.attachment_base_url,
body=get_serialized_result(attachments_resource_sys_id),
status=200,
content_type="application/json")

result = self.resource.attachments.get(sys_id=mock_sys_id)
self.assertEqual(attachments_resource_sys_id, list(result))

@httpretty.activate
def test_upload_binary(self):
"""Uploading with multipart should append /file to URL"""

httpretty.register_uri(httpretty.POST,
self.attachment_url_binary,
body=get_serialized_result(attachment),
status=201,
content_type="application/json")

result = self.resource.attachments.upload(sys_id=mock_sys_id,
file_path=attachment_path)

self.assertEqual(result, attachment)

@httpretty.activate
def test_upload_multipart(self):
"""Uploading with multipart should append /upload to URL"""

httpretty.register_uri(httpretty.POST,
self.attachment_url_multipart,
body=get_serialized_result(attachment),
status=201,
content_type="application/json")

result = self.resource.attachments.upload(sys_id=mock_sys_id,
file_path=attachment_path,
multipart=True)

self.assertEqual(result, attachment)

@httpretty.activate
def test_upload_delete(self):
"""Deleting an attachment should trigger pysnow to perform a lookup followed by a delete"""

httpretty.register_uri(httpretty.GET,
self.attachment_base_url,
body=get_serialized_result(attachment),
status=200,
content_type="application/json")

httpretty.register_uri(httpretty.DELETE,
self.attachment_url_sys_id,
body=get_serialized_result(delete_status),
status=204,
content_type="application/json")

result = self.resource.attachments.delete(mock_sys_id)

self.assertEqual(result, delete_status)

def test_upload_invalid_multipart_type(self):
"""Passing a non-bool type as multipart argument should raise InvalidUsage"""

a = self.resource.attachments
self.assertRaises(InvalidUsage, a.upload, mock_sys_id, attachment_path, multipart=dict())
Loading

0 comments on commit 9bc7230

Please sign in to comment.