def test_parse_module_url(): from sasctl.tasks import _parse_module_url body = RestObj({'createdBy': 'sasdemo', 'creationTimeStamp': '2019-08-26T15:16:42.900Z', 'destinationName': 'maslocal', 'id': '62cae262-7287-412b-8f1d-bd2a12c8b434', 'links': [{'href': '/modelPublish/models/44d526bc-d513-4637-b8a7-72daee4a7730', 'method': 'GET', 'rel': 'up', 'type': 'application/vnd.sas.models.publishing.publish', 'uri': '/modelPublish/models/44d526bc-d513-4637-b8a7-72daee4a7730'}, {'href': '/modelPublish/models/44d526bc-d513-4637-b8a7-72daee4a7730/log', 'method': 'GET', 'rel': 'self', 'type': 'application/json', 'uri': '/modelPublish/models/44d526bc-d513-4637-b8a7-72daee4a7730/log'}], 'log': 'SUCCESS==={"links":[{"method":"GET","rel":"up","href":"/microanalyticScore/jobs","uri":"/microanalyticScore/jobs","type":"application/vnd.sas.collection","itemType":"application/vnd.sas.microanalytic.job"},{"method":"GET","rel":"self","href":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3","uri":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3","type":"application/vnd.sas.microanalytic.job"},{"method":"GET","rel":"source","href":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3/source","uri":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3/source","type":"application/vnd.sas.microanalytic.module.source"},{"method":"GET","rel":"submodules","href":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3/submodules","uri":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3/submodules","type":"application/vnd.sas.collection","itemType":"application/vnd.sas.microanalytic.submodule"},{"method":"DELETE","rel":"delete","href":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3","uri":"/microanalyticScore/jobs/465ecad8-cfd0-4403-ac8a-e49cd248fae3"},{"method":"GET","rel":"module","href":"/microanalyticScore/modules/decisiontree","uri":"/microanalyticScore/modules/decisiontree","type":"application/vnd.sas.microanalytic.module"}],"version":1,"createdBy":"sasdemo","creationTimeStamp":"2019-08-26T15:16:42.857Z","modifiedBy":"sasdemo","modifiedTimeStamp":"2019-08-26T15:16:48.988Z","id":"465ecad8-cfd0-4403-ac8a-e49cd248fae3","moduleId":"decisiontree","state":"completed","errors":[]}', 'modelId': '459aae0d-d64f-4376-94e7-be31911f4bdb', 'modelName': 'DecisionTree', 'modifiedBy': 'sasdemo', 'modifiedTimeStamp': '2019-08-26T15:16:49.315Z', 'publishName': 'Decision Tree', 'version': 1}) msg = body.get('log').lstrip('SUCßCESS===') assert _parse_module_url(msg) == '/microanalyticScore/modules/decisiontree'
def side_effect(_, link, **kwargs): assert 'limit=%d' % limit in link start = int(re.search(r'(?<=start=)[\d]+', link).group()) if start == 10: return RestObj(items=pages[1]) elif start == 20: return RestObj(items=pages[2]) else: return RestObj(items=[])
def test_list_items(): from sasctl.core import _build_crud_funcs, RestObj list_items, _, _, _ = _build_crud_funcs('/items') with mock.patch('sasctl.core.request') as request: request.return_value = RestObj() resp = list_items() assert request.call_count == 1 assert [RestObj()] == resp
def test_getitem_no_paging(): items = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}] obj = RestObj(items=items, count=len(items)) with mock.patch('sasctl.core.request') as request: l = PagedList(obj) for i in range(len(l)): item = l[i] assert RestObj(items[i]) == item # No request should have been made to retrieve additional data. request.assert_not_called()
def test_len_no_paging(): items = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}] obj = RestObj(items=items, count=len(items)) with mock.patch('sasctl.core.request') as request: l = PagedList(obj) assert len(l) == 3 for i, o in enumerate(l): assert RestObj(items[i]) == o # No request should have been made to retrieve additional data. request.assert_not_called()
def test_no_paging_required(): """If "next" link not present, current items should be included.""" items = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}] obj = RestObj(items=items, count=len(items)) with mock.patch('sasctl.core.request') as request: pager = PagedItemIterator(obj) for i, o in enumerate(pager): assert RestObj(items[i]) == o # No request should have been made to retrieve additional data. request.assert_not_called()
def test_is_iterator(): """PagedItemIterator should be an iterator itself.""" items = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}] obj = RestObj(items=items, count=len(items)) with mock.patch('sasctl.core.request') as request: pager = PagedItemIterator(obj) for i in range(len(items)): o = next(pager) assert RestObj(items[i]) == o # No request should have been made to retrieve additional data. request.assert_not_called()
def test_convert_to_list(): """Converts correctly to a list.""" items = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}] obj = RestObj(items=items, count=len(items)) with mock.patch('sasctl.core.request') as request: pager = PagedItemIterator(obj) # Can convert to list target = [RestObj(i) for i in items] assert list(pager) == target # No request should have been made to retrieve additional data. request.assert_not_called()
def test_bugfix_27(): """NaN values should be converted to null before being sent to MAS https://github.com/sassoftware/python-sasctl/issues/27 """ import io from sasctl.core import RestObj from sasctl.services import microanalytic_score as mas pd = pytest.importorskip('pandas') df = pd.read_csv(io.StringIO('\n'.join([ u'BAD,LOAN,MORTDUE,VALUE,REASON,JOB,YOJ,DEROG,DELINQ,CLAGE,NINQ,CLNO,DEBTINC', u'0,1.0,1100.0,25860.0,39025.0,HomeImp,Other,10.5,0.0,0.0,94.36666667,1.0,9.0,', u'1,1.0,1300.0,70053.0,68400.0,HomeImp,Other,7.0,0.0,2.0,121.8333333,0.0,14.0,' ]))) with mock.patch('sasctl._services.microanalytic_score.MicroAnalyticScore.get_module') as get_module: get_module.return_value = RestObj({ 'name': 'Mock Module', 'id': 'mockmodule' }) with mock.patch('sasctl._services.microanalytic_score.MicroAnalyticScore.post') as post: x = df.iloc[0, :] mas.execute_module_step('module', 'step', **x) # Ensure we're passing NaN to execute_module_step assert pd.isna(x['DEBTINC']) # Make sure the value has been converted to None before being serialized to JSON. # This ensures that the JSON value will be null. json = post.call_args[1]['json'] inputs = json['inputs'] debtinc = [i for i in inputs if i['name'] == 'DEBTINC'].pop() assert debtinc['value'] is None
def test_no_paging_required(): """If "next" link not present, current items should be included.""" items = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}] obj = RestObj(items=items, count=len(items)) with mock.patch('sasctl.core.request') as request: pager = PageIterator(obj) # Returned page of items should preserve item order items = next(pager) for idx, item in enumerate(items): assert item.name == RestObj(items[idx]).name # No request should have been made to retrieve additional data. request.assert_not_called()
def test_save_performance_project_types(): from sasctl.tasks import update_model_performance with mock.patch( 'sasctl._services.model_repository.ModelRepository.get_model' ) as model: with mock.patch( 'sasctl._services.model_repository.ModelRepository.get_project' ) as project: model.return_value = RestObj(name='fakemodel', projectId=1) # Function is required with pytest.raises(ValueError): project.return_value = {} update_model_performance(None, None, None) # Target Level is required with pytest.raises(ValueError): project.return_value = {'function': 'Prediction'} update_model_performance(None, None, None) # Prediction variable required with pytest.raises(ValueError): project.return_value = { 'function': 'Prediction', 'targetLevel': 'Binary' } update_model_performance(None, None, None)
def test_paging_inflated_count(): """Test behavior when server overestimates the number of items available.""" import re start = 10 limit = 10 # Only defines 20 items to return pages = [ [{ 'name': x } for x in list('abcdefghi')], [{ 'name': x } for x in list('klmnopqrs')], [{ 'name': x } for x in list('uv')], ] actual_num_items = sum(len(page) for page in pages) # services (like Files) may overestimate how many items are available. # Simulate that behavior num_items = 23 obj = RestObj(items=pages[0], count=num_items, links=[{ 'rel': 'next', 'href': '/moaritems?start=%d&limit=%d' % (start, limit) }]) with mock.patch('sasctl.core.request') as req: def side_effect(_, link, **kwargs): assert 'limit=%d' % limit in link start = int(re.search(r'(?<=start=)[\d]+', link).group()) if start == 10: return RestObj(items=pages[1]) elif start == 20: return RestObj(items=pages[2]) else: return RestObj(items=[]) req.side_effect = side_effect pager = PagedItemIterator(obj, threads=1) # Initially, length is estimated based on how many items the server says it has assert len(pager) == num_items # Retrieve all of the items items = [x for x in pager] assert len(items) == actual_num_items assert len(pager) == num_items - actual_num_items
def test_paging(paging): """Test that correct paging requests are made.""" obj, items, _ = paging pager = PagedItemIterator(obj) for i, o in enumerate(pager): assert RestObj(items[i]) == o
def test_update_item(): from sasctl.core import _build_crud_funcs, RestObj _, _, update_item, _ = _build_crud_funcs('/widget') target = RestObj({'name': 'Test Widget', 'id': 12345}) with mock.patch('sasctl.core.request') as request: request.return_value = target # ETag should be required with pytest.raises(ValueError): resp = update_item(target) target._headers = {'etag': 'abcd'} resp = update_item(target) assert request.call_count == 1 assert ('put', '/widget/12345') == request.call_args[0] assert target == resp
def test_zip_paging(paging): """Check that zip() works correctly with the list.""" obj, items, _ = paging l = PagedList(obj) # length of list should equal total # of items assert len(l) == len(items) for target, actual in zip(items, l): assert RestObj(target).name == actual.name
def test_getitem_paging(paging): """Check that list can be enumerated.""" obj, items, _ = paging l = PagedList(obj) # length of list should equal total # of items assert len(l) == len(items) for i, item in enumerate(l): assert item.name == RestObj(items[i]).name
def test_put_restobj(): from sasctl.core import put, RestObj url = "/jobDefinitions/definitions/717331fa-f650-4e31-b9e2-6e6d49f66bf9" obj = RestObj({'_headers': {'etag': 123, 'content-type': 'spam'}}) # Base case with mock.patch('sasctl.core.request') as req: put(url, obj) assert req.called args = req.call_args[0] kwargs = req.call_args[1] assert args == ('put', url) assert kwargs['json'] == obj assert kwargs['headers']['If-Match'] == 123 assert kwargs['headers']['Content-Type'] == 'spam' # Should merge with explicit headers with mock.patch('sasctl.core.request') as req: put(url, obj, headers={'encoding': 'spammy'}) assert req.called args = req.call_args[0] kwargs = req.call_args[1] assert args == ('put', url) assert kwargs['json'] == obj assert kwargs['headers']['If-Match'] == 123 assert kwargs['headers']['Content-Type'] == 'spam' assert kwargs['headers']['encoding'] == 'spammy' # Should not overwrite explicit headers with mock.patch('sasctl.core.request') as req: put(url, obj, headers={ 'Content-Type': 'notspam', 'encoding': 'spammy' }) assert req.called args = req.call_args[0] kwargs = req.call_args[1] assert args == ('put', url) assert kwargs['json'] == obj assert kwargs['headers']['If-Match'] == 123 assert kwargs['headers']['Content-Type'] == 'notspam' assert kwargs['headers']['encoding'] == 'spammy'
def test_copy(paging): """Check that [:] syntax works correctly with the list.""" obj, items, _ = paging l = PagedList(obj) # length of list should equal total # of items assert len(l) == len(items) target = items[:] actual = l[:] assert len(actual) == len(l) for i, item in enumerate(actual): assert item.name == RestObj(target[i]).name
def test_paging_required(paging): """Requests should be made to retrieve additional pages.""" obj, items, _ = paging pager = PageIterator(obj) init_count = pager._start for i, page in enumerate(pager): for j, item in enumerate(page): if i == 0: item_idx = j else: # Account for initial page size not necessarily being same size # as additional pages item_idx = init_count + (i-1) * pager._limit + j target = RestObj(items[item_idx]) assert item.name == target.name
def test_slice_paging(paging): """Check that [i:j] syntax works correctly with the list.""" obj, items, _ = paging l = PagedList(obj) # length of list should equal total # of items assert len(l) == len(items) # Generate pairs of start:stop indexes that intentionally exceed # the size of the array, and include empty sequences. starts = range(len(l) + 1) stops = range(len(l), -1, -1) for start, stop in zip(starts, stops): target = items[start:stop] actual = l[start:stop] for i, item in enumerate(actual): assert item.name == RestObj(target[i]).name
def paging(request): """Create a RestObj designed to page through a collection of items and the collection itself. Returns ------- RestObj : initial RestObj that can be used to initialize a paging iterator List[dict] : List of items being used as the "server-side" source MagicMock : Mock of sasctl.request for performing additional validation """ import math import re num_items, start, limit = request.param with mock.patch('sasctl.core.request') as req: items = [{'name': str(i)} for i in range(num_items)] obj = RestObj(items=items[:start], count=len(items), links=[{'rel': 'next', 'href': '/moaritems?start=%d&limit=%d' % ( start, limit)}]) def side_effect(_, link, **kwargs): assert 'limit=%d' % limit in link start = int(re.search(r'(?<=start=)[\d]+', link).group()) return RestObj(items=items[start:start + limit]) req.side_effect = side_effect yield obj, items[:], req # Enough requests should have been made to retrieve all the data. # Additional requests may have been made by workers to non-existent pages. call_count = (num_items - start) / float(limit) assert req.call_count >= math.ceil(call_count)
def test_str(): """Check str formatting of list.""" source_items = [{'name': 'a'}, {'name': 'b'}, {'name': 'c'}, {'name': 'd'}, {'name': 'e'}, {'name': 'f'}] start = 2 limit = 2 with mock.patch('sasctl.core.request') as req: obj = RestObj(items=source_items[:2], count=len(source_items), links=[{'rel': 'next', 'href': '/moaritems?start=%d&limit=%d' % ( start, limit)}]) def side_effect(_, link, **kwargs): if 'start=2' in link: result = source_items[1:1+limit] elif 'start=4' in link: result = source_items[3:3+limit] return RestObj(items=result) req.side_effect = side_effect l = PagedList(obj) for i in range(len(source_items)): # Force access of each item to ensure it's downloaded _ = l[i] if i < len(source_items) - 1: # Ellipses should indicate unfetched results unless we're # at the end of the list assert str(l).endswith(', ... ]') else: assert not str(l).endswith(', ... ]')
def test_define_steps_invalid_name(): """Verify that invalid characters are stripped.""" # Mock module to be returned # From bug reported by Paata module = RestObj({ 'createdBy': 'paata', 'creationTimeStamp': '2020-03-02T15:37:57.811Z', 'id': 'petshop_model_xgb_new', 'language': 'ds2', 'links': [{ 'href': '/microanalyticScore/modules', 'itemType': 'application/vnd.sas.microanalytic.module', 'method': 'GET', 'rel': 'up', 'type': 'application/vnd.sas.collection', 'uri': '/microanalyticScore/modules' }, { 'href': '/microanalyticScore/modules/petshop_model_xgb_new', 'method': 'GET', 'rel': 'self', 'type': 'application/vnd.sas.microanalytic.module', 'uri': '/microanalyticScore/modules/petshop_model_xgb_new' }, { 'href': '/microanalyticScore/modules/petshop_model_xgb_new/source', 'method': 'GET', 'rel': 'source', 'type': 'application/vnd.sas.microanalytic.module.source', 'uri': '/microanalyticScore/modules/petshop_model_xgb_new/source' }, { 'href': '/microanalyticScore/modules/petshop_model_xgb_new/steps', 'itemType': 'application/vnd.sas.microanalytic.module.step', 'method': 'GET', 'rel': 'steps', 'type': 'application/vnd.sas.collection', 'uri': '/microanalyticScore/modules/petshop_model_xgb_new/steps' }, { 'href': '/microanalyticScore/modules/petshop_model_xgb_new/submodules', 'itemType': 'application/vnd.sas.microanalytic.submodule', 'method': 'GET', 'rel': 'submodules', 'type': 'application/vnd.sas.collection', 'uri': '/microanalyticScore/modules/petshop_model_xgb_new/submodules' }, { 'href': '/microanalyticScore/modules/petshop_model_xgb_new', 'method': 'PUT', 'rel': 'update', 'responseType': 'application/vnd.sas.microanalytic.module', 'type': 'application/vnd.sas.microanalytic.module', 'uri': '/microanalyticScore/modules/petshop_model_xgb_new' }, { 'href': '/microanalyticScore/modules/petshop_model_xgb_new', 'method': 'DELETE', 'rel': 'delete', 'uri': '/microanalyticScore/modules/petshop_model_xgb_new' }], 'modifiedBy': 'paata', 'modifiedTimeStamp': '2020-03-02T15:57:10.008Z', 'name': '"petshop_model_XGB_new"', 'properties': [{ 'name': 'sourceURI', 'value': 'http://modelmanager/modelRepository/models/72facedf-8e36-418e-8145-1398686b997a' }], 'revision': 0, 'scope': 'public', 'stepIds': ['score'], 'version': 2, 'warnings': [] }) # Mock module step with multiple inputs step2 = RestObj({ "id": "score", "inputs": [{ "name": "age", "type": "decimal", "dim": 0, "size": 0 }, { "name": "b", "type": "decimal", "dim": 0, "size": 0 }, { "name": "chas", "type": "decimal", "dim": 0, "size": 0 }, { "name": "crim", "type": "decimal", "dim": 0, "size": 0 }, { "name": "dis", "type": "decimal", "dim": 0, "size": 0 }, { "name": "indus", "type": "decimal", "dim": 0, "size": 0 }, { "name": "lstat", "type": "decimal", "dim": 0, "size": 0 }, { "name": "nox", "type": "decimal", "dim": 0, "size": 0 }, { "name": "ptratio", "type": "decimal", "dim": 0, "size": 0 }, { "name": "rad", "type": "decimal", "dim": 0, "size": 0 }, { "name": "rm", "type": "decimal", "dim": 0, "size": 0 }, { "name": "tax", "type": "decimal", "dim": 0, "size": 0 }, { "name": "zn", "type": "decimal", "dim": 0, "size": 0 }], "outputs": [{ "name": "em_prediction", "type": "decimal", "dim": 0, "size": 0 }, { "name": "p_price", "type": "decimal", "dim": 0, "size": 0 }, { "name": "_warn_", "type": "string", "dim": 0, "size": 4 }] }) with mock.patch( 'sasctl._services.microanalytic_score.MicroAnalyticScore.get_module' ) as get_module: with mock.patch( 'sasctl._services.microanalytic_score.MicroAnalyticScore' '.get_module_step') as get_step: get_module.return_value = module get_step.side_effect = [step2] result = mas.define_steps(None) for step in get_step.side_effect: assert hasattr(result, step.id)
def test_define_steps(): # Mock module to be returned module = RestObj(name='unittestmodule', stepIds=['step1', 'step2']) # Mock module step with no inputs step1 = RestObj(id='post') # Mock module step with multiple inputs step2 = RestObj({ "id": "score", "inputs": [{ "name": "age", "type": "decimal", "dim": 0, "size": 0 }, { "name": "b", "type": "decimal", "dim": 0, "size": 0 }, { "name": "chas", "type": "decimal", "dim": 0, "size": 0 }, { "name": "crim", "type": "decimal", "dim": 0, "size": 0 }, { "name": "dis", "type": "decimal", "dim": 0, "size": 0 }, { "name": "indus", "type": "decimal", "dim": 0, "size": 0 }, { "name": "lstat", "type": "decimal", "dim": 0, "size": 0 }, { "name": "nox", "type": "decimal", "dim": 0, "size": 0 }, { "name": "ptratio", "type": "decimal", "dim": 0, "size": 0 }, { "name": "rad", "type": "decimal", "dim": 0, "size": 0 }, { "name": "rm", "type": "decimal", "dim": 0, "size": 0 }, { "name": "tax", "type": "decimal", "dim": 0, "size": 0 }, { "name": "zn", "type": "decimal", "dim": 0, "size": 0 }], "outputs": [{ "name": "em_prediction", "type": "decimal", "dim": 0, "size": 0 }, { "name": "p_price", "type": "decimal", "dim": 0, "size": 0 }, { "name": "_warn_", "type": "string", "dim": 0, "size": 4 }] }) with mock.patch( 'sasctl._services.microanalytic_score.MicroAnalyticScore.get_module' ) as get_module: with mock.patch( 'sasctl._services.microanalytic_score.MicroAnalyticScore' '.get_module_step') as get_step: get_module.return_value = module get_step.side_effect = [step1, step2] result = mas.define_steps(None) for step in get_step.side_effect: assert hasattr(result, step.id)
def test_get_item_inflated_len(): """Test behavior when server overestimates the number of items available.""" import re start = 10 limit = 10 # Only defines 20 items to return pages = [ [{'name': x} for x in list('abcdefghi')], [{'name': x} for x in list('klmnopqrs')], [{'name': x} for x in list('uv')], ] actual_num_items = sum(len(page) for page in pages) # services (like Files) may overestimate how many items are available. # Simulate that behavior num_items = 23 obj = RestObj(items=pages[0], count=num_items, links=[{'rel': 'next', 'href': '/moaritems?start=%d&limit=%d' % (start, limit)}]) with mock.patch('sasctl.core.request') as req: def side_effect(_, link, **kwargs): assert 'limit=%d' % limit in link start = int(re.search(r'(?<=start=)[\d]+', link).group()) if start == 10: return RestObj(items=pages[1]) elif start == 20: return RestObj(items=pages[2]) else: return RestObj(items=[]) req.side_effect = side_effect pager = PagedList(obj, threads=1) # Initially, length is estimated based on how many items the server says it has available assert len(pager) == num_items # Retrieve all of the items items = [x for x in pager] # Should have retrieved all of the items assert len(items) == actual_num_items # List length should now be updated to indicate the correct number of items assert len(pager) == actual_num_items # Recreate the pager with mock.patch('sasctl.core.request') as req: def side_effect(_, link, **kwargs): assert 'limit=%d' % limit in link start = int(re.search(r'(?<=start=)[\d]+', link).group()) if start == 10: return RestObj(items=pages[1]) elif start == 20: return RestObj(items=pages[2]) else: return RestObj(items=[]) req.side_effect = side_effect pager = PagedList(obj, threads=1) # Requesting the last item should work & cause loading of all items last_item = pager[-1] # Make sure the last item is correct (even though server inflated item count) assert last_item == pages[-1][-1] assert len(pager) == actual_num_items
def side_effect(_, link, **kwargs): assert 'limit=%d' % limit in link start = int(re.search(r'(?<=start=)[\d]+', link).group()) return RestObj(items=items[start:start + limit])
def side_effect(_, link, **kwargs): if 'start=2' in link: result = source_items[1:1+limit] elif 'start=4' in link: result = source_items[3:3+limit] return RestObj(items=result)