Skip to content

Commit

Permalink
Merge pull request #91 from rbw0/branch-0.7.3
Browse files Browse the repository at this point in the history
Branch 0.7.3
  • Loading branch information
rbw authored Mar 31, 2018
2 parents cb514b7 + 554775b commit 44e3ce5
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 73 deletions.
15 changes: 14 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,24 @@
News
----

Version 0.7 released
**Version 0.7 released**

This release comes with a new attachment helper, available in *table-type* `Resources`.
Go `here <http://pysnow.readthedocs.io/en/latest/api/attachment.html>`_ for its API documentation, or check out an `example <http://pysnow.readthedocs.io/en/latest/full_examples/attachments.html>`_.

Also, the ``Response`` interface has been further improved and now allows chaining. Example:

.. code-block:: python
incidents = c.resource(api_path='/table/incident')
incident = incidents.get(query={'number': 'INC01234'})
print('uploading last words to incident: {0}'.format(incident['sys_id']))
incident.upload(file_path='/tmp/last_words.txt')
incident.update({'description': 'Bye bye'})
incident.delete()
Additionally, generator / streamed responses are now default off, but can be easily enabled by passing stream=True to ``Resource.get`` for those memory-intensive queries.


Documentation
-------------
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.7.1"
__version__ = "0.7.3"

# Set default logging handler to avoid "No handler found" warnings.
import logging
Expand Down
2 changes: 1 addition & 1 deletion pysnow/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def upload(self, sys_id, file_path, name=None, multipart=False):
headers["Content-Type"] = "text/plain"
path_append = '/file'

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

def delete(self, sys_id):
"""Deletes the provided attachment record
Expand Down
22 changes: 12 additions & 10 deletions pysnow/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ class SnowRequest(object):
:param url_builder: :class:`url_builder.URLBuilder` object
"""

def __init__(self, parameters=None, session=None, url_builder=None, chunk_size=None, parent=None):
def __init__(self, parameters=None, session=None, url_builder=None, chunk_size=None, resource=None):
self._parameters = parameters
self._url_builder = url_builder
self._session = session
self._chunk_size = chunk_size
self._parent = parent
self._resource = resource

self._url = url_builder.get_url()

Expand All @@ -37,24 +37,26 @@ def _get_response(self, method, **kwargs):
"""

params = self._parameters.as_dict()
use_stream = kwargs.pop('stream', False)

logger.debug('(REQUEST_SEND) Method: %s, Resource: %s' % (method, self._parent))
logger.debug('(REQUEST_SEND) Method: %s, Resource: %s' % (method, self._resource))

response = self._session.request(method, self._url, stream=True, params=params, **kwargs)
response = self._session.request(method, self._url, stream=use_stream, params=params, **kwargs)
response.raw.decode_content = True

logger.debug('(RESPONSE_RECEIVE) Code: %d, Resource: %s' % (response.status_code, self._parent))
logger.debug('(RESPONSE_RECEIVE) Code: %d, Resource: %s' % (response.status_code, self._resource))

return Response(response, self._chunk_size)
return Response(response=response, resource=self._resource, chunk_size=self._chunk_size, stream=use_stream)

def get(self, query, limit=None, offset=None, fields=list()):
def get(self, query, limit=None, offset=None, fields=list(), stream=False):
"""Fetches one or more records, exposes a public API of :class:`pysnow.Response`
:param query: Dictionary, string or :class:`QueryBuilder` object
:param limit: Limits the number of records returned
:param fields: List of fields to include in the response
created_on in descending order.
:param offset: Number of records to skip before returning records
:param stream: Whether or not to use streaming / generator response interface
:return:
- :class:`pysnow.Response` object
"""
Expand All @@ -70,7 +72,7 @@ def get(self, query, limit=None, offset=None, fields=list()):
if len(fields) > 0:
self._parameters.fields = fields

return self._get_response('GET')
return self._get_response('GET', stream=stream)

def create(self, payload):
"""Creates a new record
Expand All @@ -80,7 +82,7 @@ def create(self, payload):
- Dictionary of the inserted record
"""

return self._get_response('POST', data=json.dumps(payload)).one()
return self._get_response('POST', data=json.dumps(payload))

def update(self, query, payload):
"""Updates a record
Expand All @@ -97,7 +99,7 @@ def update(self, query, payload):
record = self.get(query).one()

self._url = self._url_builder.get_appended_custom("/{0}".format(record['sys_id']))
return self._get_response('PUT', data=json.dumps(payload)).one()
return self._get_response('PUT', data=json.dumps(payload))

def delete(self, query):
"""Deletes a record
Expand Down
7 changes: 4 additions & 3 deletions pysnow/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def _request(self):

parameters = copy(self.parameters)

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

def get_record_link(self, sys_id):
"""Provides full URL to the provided sys_id
Expand All @@ -89,18 +89,19 @@ def get_record_link(self, sys_id):

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

def get(self, query, limit=None, offset=None, fields=list()):
def get(self, query, limit=None, offset=None, fields=list(), stream=False):
"""Queries the API resource
:param query: Dictionary, string or :class:`QueryBuilder` object
:param limit: (optional) Limits the number of records returned
:param fields: (optional) List of fields to include in the response created_on in descending order.
:param offset: (optional) Number of records to skip before returning records
:param stream: Whether or not to use streaming / generator response interface
:return:
- :class:`Response` object
"""

return self._request.get(query, limit, offset, fields)
return self._request.get(query, limit, offset, fields, stream)

def create(self, payload):
"""Creates a new record in the API resource
Expand Down
118 changes: 86 additions & 32 deletions pysnow/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,28 @@
import ijson

from ijson.common import ObjectBuilder

from itertools import chain

from .exceptions import (ResponseError,
NoResults,
InvalidUsage,
MultipleResults,
MissingResult)


class Response(object):
"""Takes a :class:`requests.Response` object and performs deserialization and validation.
:param response: :class:`request.Response` object
:param response: :class:`requests.Response` object
:param resource: parent :class:`resource.Resource` object
:param chunk_size: Read and return up to this size (in bytes) in the stream parser
"""

def __init__(self, response, chunk_size=2048):
def __init__(self, response, resource, chunk_size=2048, stream=False):
self._response = response
self._chunk_size = chunk_size
self._count = 0
self._resource = resource
self._stream = stream

@property
def count(self):
Expand All @@ -35,6 +37,9 @@ def count(self, count):

self._count = count

def __getitem__(self, key):
return self.one().get(key)

def __repr__(self):
return '<%s [%d - %s]>' % (self.__class__.__name__, self._response.status_code, self._response.request.method)

Expand All @@ -47,18 +52,20 @@ def _parse_response(self):
- MissingResult: If no result nor error was found
"""

response = self._get_response()

has_result_single = False
has_result_many = False
has_error = False

for prefix, event, value in ijson.parse(self._response.raw, buf_size=self._chunk_size):
builder = ObjectBuilder()

for prefix, event, value in ijson.parse(response.raw, buf_size=self._chunk_size):
if (prefix, event) == ('error', 'start_map'):
# Matched ServiceNow `error` object at the root
has_error = True
builder = ObjectBuilder()
elif prefix == 'result' and event in ['start_map', 'start_array']:
# Matched ServiceNow `result`
builder = ObjectBuilder()
if event == 'start_map': # Matched object
has_result_single = True
elif event == 'start_array': # Matched array
Expand Down Expand Up @@ -97,26 +104,47 @@ def _parse_response(self):
if not (has_result_single or has_result_many or has_error): # None of the expected keys were found
raise MissingResult('The expected `result` key was missing in the response. Cannot continue')

def _get_validated_response(self):
"""Validates response then calls :meth:`_parse_response` to yield content
def _get_response(self):
response = self._response

Immediately yields response content if request method is DELETE and code 204 (this response never
contains a body).
# Raise an HTTPError if we hit a non-200 status code
response.raise_for_status()

:raise:
- HTTPError: if a non-200 response is encountered
return response

def _get_streamed_response(self):
"""Parses byte stream (memory efficient)
:return: Parsed JSON
"""

response = self._response
yield self._parse_response()

def _get_buffered_response(self):
"""Returns a buffered response
:return: Buffered response
"""

response = self._get_response()

if response.request.method == 'DELETE' and response.status_code == 204:
yield [{'status': 'record deleted'}]
else:
# Raise an HTTPError if we hit a non-200 status code
response.raise_for_status()
return [{'status': 'record deleted'}], 1

result = self._response.json().get('result', None)

if result is None:
raise MissingResult('The expected `result` key was missing in the response. Cannot continue')

length = 0

if isinstance(result, list):
length = len(result)
elif isinstance(result, dict):
result = [result]
length = 1

# Parse byte stream
yield self._parse_response()
return result, length

def all(self):
"""Returns a chained generator response containing all matching records
Expand All @@ -125,7 +153,10 @@ def all(self):
- Iterable response
"""

return chain.from_iterable(self._get_validated_response())
if self._stream:
return chain.from_iterable(self._get_streamed_response())

return self._get_buffered_response()[0]

def first(self):
"""Return the first record or raise an exception if the result doesn't contain any data
Expand All @@ -137,6 +168,9 @@ def first(self):
- NoResults: If no results were found
"""

if not self._stream:
raise InvalidUsage('first() is only available when stream=True')

try:
content = next(self.all())
except StopIteration:
Expand Down Expand Up @@ -167,21 +201,14 @@ def one(self):
- NoResults: If the result is empty
"""

r = self.all()
result, count = self._get_buffered_response()

try:
result = next(r)
except StopIteration:
if count == 0:
raise NoResults("No records found")

try:
next(r)
except StopIteration:
pass
else:
elif count > 1:
raise MultipleResults("Expected single-record result, got multiple")

return result
return result[0]

def one_or_none(self):
"""Return at most one record or raise an exception.
Expand All @@ -197,3 +224,30 @@ def one_or_none(self):
return self.one()
except NoResults:
return None

def update(self, payload):
"""Convenience method for updating a fetched record
:param payload: update payload
:return: update response object
"""

return self._resource.update({'sys_id': self['sys_id']}, payload)

def delete(self):
"""Convenience method for deleting a fetched record
:return: delete response object
"""

return self._resource.delete({'sys_id': self['sys_id']})

def upload(self, *args, **kwargs):
"""Convenience method for attaching files to a fetched record
:param args: args to pass along to `Attachment.upload`
:param kwargs: kwargs to pass along to `Attachment.upload`
:return: upload response object
"""

return self._resource.attachments.upload(self['sys_id'], *args, **kwargs)
Loading

0 comments on commit 44e3ce5

Please sign in to comment.