diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be61c2..bfd5d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.12] +### Fixed +- Handle the case where a product has no temporal neighbors. + ## [0.0.11] ### Added - Added CORS support to product bucket in order to support Vertex's "Download All" feature. diff --git a/find_new/requirements.txt b/find_new/requirements.txt index c7aee93..582b272 100644 --- a/find_new/requirements.txt +++ b/find_new/requirements.txt @@ -1,4 +1,4 @@ -asf-search==3.0.6 +asf-search==3.1.3 hyp3-sdk>=1.3.2 python-dateutil requests diff --git a/find_new/src/find_new.py b/find_new/src/find_new.py index c54eca4..49895e7 100644 --- a/find_new/src/find_new.py +++ b/find_new/src/find_new.py @@ -75,29 +75,40 @@ def add_invalid_product_record(event_id, granule, message): def get_neighbors(product_name: str, max_neighbors: int = 2) -> List[dict]: + if max_neighbors < 1: + raise ValueError(f"max_neighbors must be >= 1 but got {max_neighbors}") + results = asf_search.product_search([product_name]) assert len(results) == 1 granule: asf_search.ASFProduct = results[0] stack = asf_search.baseline_search.stack_from_product(granule) stack = [item for item in stack if item.properties['temporalBaseline'] < 0] - neighbors = [item.properties['fileID'] for item in stack[-max_neighbors:]] - - response = requests.post( - SEARCH_URL, - params={ - 'product_list': ','.join(neighbors), - 'output': 'jsonlite' - } - ) - - status_code = str(response.status_code) - if status_code[0] == '4': - raise asf_search.ASFSearch4xxError() - elif status_code[0] == '5': - raise asf_search.ASFSearch5xxError() + neighbor_names = [item.properties['fileID'] for item in stack[-max_neighbors:]] + + if len(neighbor_names) == 0: + neighbors = [] + else: + response = requests.post( + SEARCH_URL, + params={ + 'product_list': ','.join(neighbor_names), + 'output': 'jsonlite' + } + ) - return response.json()['results'] + status_code = str(response.status_code) + if status_code[0] == '4': + raise asf_search.ASFSearch4xxError() + elif status_code[0] == '5': + raise asf_search.ASFSearch5xxError() + + neighbors = response.json()['results'] + + assert len(neighbors) <= max_neighbors + assert len(neighbors) == len(neighbor_names) + + return neighbors def submit_jobs_for_granule(hyp3, event_id, granule): diff --git a/tests/test_find_new.py b/tests/test_find_new.py index 6422edb..39f7e2f 100644 --- a/tests/test_find_new.py +++ b/tests/test_find_new.py @@ -1,6 +1,6 @@ import json from os import environ -from unittest.mock import patch +from unittest.mock import MagicMock, NonCallableMagicMock, call, patch from uuid import uuid4 import asf_search @@ -204,6 +204,138 @@ def test_add_invalid_product_record(tables): assert response[0]['event_id'] == 'event_id1' +@patch('asf_search.baseline_search.stack_from_product') +@patch('asf_search.product_search') +@responses.activate +def test_get_neighbors(mock_product_search: MagicMock, mock_stack_from_product: MagicMock): + mock_granule = NonCallableMagicMock() + mock_product_search.return_value = [mock_granule] + + mock_stack_from_product.return_value = [ + NonCallableMagicMock(properties={'fileID': 'file-0', 'temporalBaseline': -3}), + NonCallableMagicMock(properties={'fileID': 'file-1', 'temporalBaseline': -2}), + NonCallableMagicMock(properties={'fileID': 'file-2', 'temporalBaseline': -1}), + NonCallableMagicMock(properties={'fileID': 'file-3', 'temporalBaseline': 0}), + NonCallableMagicMock(properties={'fileID': 'file-4', 'temporalBaseline': 1}), + NonCallableMagicMock(properties={'fileID': 'file-5', 'temporalBaseline': 2}), + ] + + mock_response_1 = {'results': [{'granuleName': 'granule2'}]} + params_1 = {'product_list': 'file-2', 'output': 'jsonlite'} + responses.post( + url=find_new.SEARCH_URL, + body=json.dumps(mock_response_1), + match=[responses.matchers.query_param_matcher(params_1)] + ) + + mock_response_2 = {'results': [{'granuleName': 'granule1'}, {'granuleName': 'granule2'}]} + params_2 = {'product_list': 'file-1,file-2', 'output': 'jsonlite'} + responses.post( + url=find_new.SEARCH_URL, + body=json.dumps(mock_response_2), + match=[responses.matchers.query_param_matcher(params_2)] + ) + + mock_response_3 = { + 'results': [{'granuleName': 'granule0'}, {'granuleName': 'granule1'}, {'granuleName': 'granule2'}] + } + params_3 = {'product_list': 'file-0,file-1,file-2', 'output': 'jsonlite'} + responses.post( + url=find_new.SEARCH_URL, + body=json.dumps(mock_response_3), + match=[responses.matchers.query_param_matcher(params_3)] + ) + + assert find_new.get_neighbors('test-product', max_neighbors=1) == mock_response_1['results'] + assert find_new.get_neighbors('test-product', max_neighbors=2) == mock_response_2['results'] + assert find_new.get_neighbors('test-product', max_neighbors=3) == mock_response_3['results'] + assert find_new.get_neighbors('test-product', max_neighbors=100) == mock_response_3['results'] + + assert mock_product_search.mock_calls == [call(['test-product']) for _ in range(4)] + assert mock_stack_from_product.mock_calls == [call(mock_granule) for _ in range(4)] + + +@patch('asf_search.baseline_search.stack_from_product') +@patch('asf_search.product_search') +@responses.activate +def test_get_neighbors_response_400(mock_product_search: MagicMock, mock_stack_from_product: MagicMock): + mock_granule = NonCallableMagicMock() + mock_product_search.return_value = [mock_granule] + + mock_stack_from_product.return_value = [ + NonCallableMagicMock(properties={'fileID': 'file1', 'temporalBaseline': -1}) + ] + + params = {'product_list': 'file1', 'output': 'jsonlite'} + responses.post( + url=find_new.SEARCH_URL, + status=400, + match=[responses.matchers.query_param_matcher(params)] + ) + + with pytest.raises(asf_search.ASFSearch4xxError): + find_new.get_neighbors('test-product') + + assert mock_product_search.mock_calls == [call(['test-product'])] + assert mock_stack_from_product.mock_calls == [call(mock_granule)] + + +@patch('asf_search.baseline_search.stack_from_product') +@patch('asf_search.product_search') +@responses.activate +def test_get_neighbors_response_500(mock_product_search: MagicMock, mock_stack_from_product: MagicMock): + mock_granule = NonCallableMagicMock() + mock_product_search.return_value = [mock_granule] + + mock_stack_from_product.return_value = [ + NonCallableMagicMock(properties={'fileID': 'file1', 'temporalBaseline': -1}) + ] + + params = {'product_list': 'file1', 'output': 'jsonlite'} + responses.post( + url=find_new.SEARCH_URL, + status=500, + match=[responses.matchers.query_param_matcher(params)] + ) + + with pytest.raises(asf_search.ASFSearch5xxError): + find_new.get_neighbors('test-product') + + assert mock_product_search.mock_calls == [call(['test-product'])] + assert mock_stack_from_product.mock_calls == [call(mock_granule)] + + +@patch('asf_search.baseline_search.stack_from_product') +@patch('asf_search.product_search') +@responses.activate +def test_get_neighbors_no_neighbors(mock_product_search: MagicMock, mock_stack_from_product: MagicMock): + mock_granule = NonCallableMagicMock() + mock_product_search.return_value = [mock_granule] + + mock_stack_from_product.return_value = [] + + assert find_new.get_neighbors('test-product') == [] + + mock_stack_from_product.return_value = [ + NonCallableMagicMock(properties={'fileID': 'file-0', 'temporalBaseline': 0}), + NonCallableMagicMock(properties={'fileID': 'file-1', 'temporalBaseline': 1}), + NonCallableMagicMock(properties={'fileID': 'file-2', 'temporalBaseline': 2}), + ] + + assert find_new.get_neighbors('test-product') == [] + + assert mock_product_search.mock_calls == [call(['test-product']) for _ in range(2)] + assert mock_stack_from_product.mock_calls == [call(mock_granule) for _ in range(2)] + + +def test_get_neighbors_max_neighbors_error(): + with pytest.raises(ValueError, match=r'.*max_neighbors.*'): + find_new.get_neighbors('test-product', max_neighbors=-1) + + with pytest.raises(ValueError, match=r'.*max_neighbors.*'): + find_new.get_neighbors('test-product', max_neighbors=0) + + @responses.activate def test_submit_jobs_for_granule(tables): responses.add(responses.GET, AUTH_URL)