diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f5ea5..96aed99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,20 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - --> +------ +## [1.0.16](https://github.com/asfadmin/Discovery-SearchAPI-v3/compare/v1.0.15...v1.0.16) +### Changed +- bump asf-search to v12.1.0 for: + - `granule_list` wildcard support + - `OPERA_L3_DIST-ALERT-S1_V1` collection update + - `NISAR_EA` collections + +### Fixed +- Fixed `getpass` typo in python snippet output + +### Style +- ruff linting on modified files + ------ ## [1.0.15](https://github.com/asfadmin/Discovery-SearchAPI-v3/compare/v1.0.14...v1.0.15) ### Changed diff --git a/README.md b/README.md index 026c7bb..d52e8ae 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ SearchAPI-v3 is a wrapper around the [asf-search python module](https://github.c feat-* Always branch off dev, for new features - + product_list with platform invalid Issues bugfix-* Always branch off dev, for bugfixes @@ -75,7 +75,7 @@ SearchAPI-v3 is a wrapper around the [asf-search python module](https://github.c production staging and integration testing prod-staging - Only acepts merges from testing, deploys to prod-staging deployment + Only accepts merges from testing, deploys to prod-staging deployment release diff --git a/requirements.txt b/requirements.txt index 322b923..4527f0f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ ujson==5.7.0 uvicorn==0.21.1 watchfiles==0.19.0 -asf-search[asf-enumeration]==12.0.6 +asf-search[asf-enumeration]==12.1.0 python-json-logger==2.0.7 pyshp==2.1.3 diff --git a/src/SearchAPI/application/application.py b/src/SearchAPI/application/application.py index 4578365..6ea6084 100644 --- a/src/SearchAPI/application/application.py +++ b/src/SearchAPI/application/application.py @@ -25,7 +25,7 @@ from asf_search import ASFSearchResults from asf_enumeration import aria_s1_gunw -asf_config['session'] = SearchAPISession() +asf_config["session"] = SearchAPISession() asf.REPORT_ERRORS = False router = APIRouter(route_class=LoggingRoute) @@ -43,7 +43,7 @@ cfg = load_config_maturity() -cmr_health = get_cmr_health(cfg['cmr_base'], cfg['cmr_health']) +cmr_health = get_cmr_health(cfg["cmr_base"], cfg["cmr_health"]) @router.api_route("/services/search/param", methods=["GET", "POST", "HEAD"]) @@ -54,37 +54,37 @@ async def query_params(searchOptions: SearchOptsModel = Depends(process_search_r output = searchOptions.output opts = searchOptions.opts - non_search_param = ['output', 'maxresults', 'pagesize', 'maturity'] + non_search_param = ["output", "maxresults", "pagesize", "maturity"] try: any_searchables = any([key.lower() not in non_search_param for key, _ in opts]) if not any_searchables: raise ValueError( - 'No searchable parameters specified, queries must include' - ' parameters besides output= and maxresults=' + "No searchable parameters specified, queries must include" + " parameters besides output= and maxresults=" ) except ValueError as exc: raise HTTPException(detail=repr(exc), status_code=400) from exc - if output.lower() == 'count': - count=asf.search_count(opts=opts) + if output.lower() == "count": + count = asf.search_count(opts=opts) return Response( content=str(count), status_code=200, - media_type='text/html; charset=utf-8', - headers=constants.DEFAULT_HEADERS + media_type="text/html; charset=utf-8", + headers=constants.DEFAULT_HEADERS, ) - if output.lower() == 'python': + if output.lower() == "python": file_name, search_script = get_asf_search_script(opts) - + return Response( content=search_script, status_code=200, - media_type='text/x-python', - headers= { - **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={file_name}", - } + media_type="text/x-python", + headers={ + **constants.DEFAULT_HEADERS, + "Content-Disposition": f"attachment; filename={file_name}", + }, ) try: results = asf.search(opts=opts) @@ -93,13 +93,14 @@ async def query_params(searchOptions: SearchOptsModel = Depends(process_search_r except (asf.ASFSearchError, asf.CMRError, ValueError) as exc: raise HTTPException( - detail=f"Search failed to find results: {exc}", - status_code=400 + detail=f"Search failed to find results: {exc}", status_code=400 ) from exc @router.api_route("/services/search/baseline", methods=["GET", "POST", "HEAD"]) -async def query_baseline(searchOptions: BaselineSearchOptsModel = Depends(process_baseline_request)): +async def query_baseline( + searchOptions: BaselineSearchOptsModel = Depends(process_baseline_request), +): opts = searchOptions.opts opts.maxResults = None output = searchOptions.output @@ -109,18 +110,22 @@ async def query_baseline(searchOptions: BaselineSearchOptsModel = Depends(proces is_frame_based = searchOptions.opts.dataset is not None # Load the reference scene: - - if output.lower() == 'python': - file_name, search_script = get_asf_search_script(opts, reference=reference, search_endpoint='baseline') - + + if "*" in reference: + raise HTTPException(detail="Reference scene cannot include wildcards", status_code=400) + if output.lower() == "python": + file_name, search_script = get_asf_search_script( + opts, reference=reference, search_endpoint="baseline" + ) + return Response( content=search_script, status_code=200, - media_type='text/x-python', - headers= { - **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={file_name}", - } + media_type="text/x-python", + headers={ + **constants.DEFAULT_HEADERS, + "Content-Disposition": f"attachment; filename={file_name}", + }, ) if is_frame_based and opts.dataset[0] == asf.DATASET.ARIA_S1_GUNW: @@ -129,12 +134,21 @@ async def query_baseline(searchOptions: BaselineSearchOptsModel = Depends(proces try: reference_product = asf.granule_search(granule_list=[reference], opts=opts)[0] except (KeyError, IndexError, ValueError) as exc: - raise HTTPException(detail=f"Reference scene not found: {reference}", status_code=400) from exc + raise HTTPException( + detail=f"Reference scene not found: {reference}", status_code=400 + ) from exc try: if reference_product.get_stack_opts() is None: - reference_product = asf.ASFStackableProduct(args={'umm': reference_product.umm, 'meta': reference_product.meta}, session=reference_product.session) - if (not reference_product.has_baseline() or not reference_product.is_valid_reference() or not reference_product.has_baseline()) and not is_frame_based: + reference_product = asf.ASFStackableProduct( + args={"umm": reference_product.umm, "meta": reference_product.meta}, + session=reference_product.session, + ) + if ( + not reference_product.has_baseline() + or not reference_product.is_valid_reference() + or not reference_product.has_baseline() + ) and not is_frame_based: raise asf.exceptions.ASFBaselineError(f"Requested reference scene has no baseline") except (asf.exceptions.ASFBaselineError, ValueError) as exc: raise HTTPException(detail=f"Search failed to find results: {exc}", status_code=400) @@ -142,28 +156,26 @@ async def query_baseline(searchOptions: BaselineSearchOptsModel = Depends(proces if request_method == "HEAD": # Need head request separately, so it doesn't do all # the work to figure out the body - if output.lower() == 'count': + if output.lower() == "count": return Response( status_code=200, - media_type='text/html; charset=utf-8', - headers=constants.DEFAULT_HEADERS + media_type="text/html; charset=utf-8", + headers=constants.DEFAULT_HEADERS, ) metadata = as_output(asf.ASFSearchResults([]), output) return Response( - status_code=200, - headers=metadata["headers"], - media_type=metadata["media_type"] + status_code=200, headers=metadata["headers"], media_type=metadata["media_type"] ) # Figure out the response params: - if output.lower() == 'count': + if output.lower() == "count": stack_opts = reference_product.get_stack_opts() count = asf.search_count(opts=stack_opts) return Response( content=str(count), status_code=200, - media_type='text/html; charset=utf-8', - headers=constants.DEFAULT_HEADERS + media_type="text/html; charset=utf-8", + headers=constants.DEFAULT_HEADERS, ) # Finally stream everything back: @@ -173,40 +185,34 @@ async def query_baseline(searchOptions: BaselineSearchOptsModel = Depends(proces return Response(**response_info) except (asf.ASFSearchError, asf.CMRError, ValueError) as exc: - raise HTTPException(detail=f"Search failed to find results: {exc}", status_code=400) from exc + raise HTTPException( + detail=f"Search failed to find results: {exc}", status_code=400 + ) from exc -@router.get('/services/utils/date', response_class=JSONResponse) +@router.get("/services/utils/date", response_class=JSONResponse) async def query_date_validation(date: str): parsed_date = dateparser.parse(date) if parsed_date is None: raise HTTPException(detail=f"Could not parse date: {date}", status_code=400) response = { - 'date': { - 'original': date, - 'parsed': parsed_date.strftime("%Y-%m-%dT%H:%M:%SZ"), + "date": { + "original": date, + "parsed": parsed_date.strftime("%Y-%m-%dT%H:%M:%SZ"), } } - return JSONResponse( - content=response, - status_code=200, - headers=constants.DEFAULT_HEADERS - ) + return JSONResponse(content=response, status_code=200, headers=constants.DEFAULT_HEADERS) -@router.get('/services/utils/mission_list', response_class=JSONResponse) +@router.get("/services/utils/mission_list", response_class=JSONResponse) async def query_mission_list(platform: str | None = None): if platform is not None: platform = platform.upper() - response = {'result': asf.campaigns(platform)} + response = {"result": asf.campaigns(platform)} - return JSONResponse( - content=response, - status_code=200, - headers=constants.DEFAULT_HEADERS - ) + return JSONResponse(content=response, status_code=200, headers=constants.DEFAULT_HEADERS) @router.api_route("/services/utils/wkt", methods=["GET", "POST"]) @@ -214,63 +220,64 @@ async def wkt_validation(wkt: str = Depends(process_wkt_request)): return Response( content=json.dumps(validate_wkt(wkt)), status_code=200, - media_type='application/json; charset=utf-8', - headers=constants.DEFAULT_HEADERS + media_type="application/json; charset=utf-8", + headers=constants.DEFAULT_HEADERS, ) -@router.post('/services/utils/files_to_wkt') + +@router.post("/services/utils/files_to_wkt") async def file_to_wkt(files: list[UploadFile]): for file in files: file.file.filename = file.filename data = FilesToWKT([file.file for file in files]).getWKT() - return JSONResponse(content={ - ** data, - ** validate_wkt(data["parsed wkt"])}, + return JSONResponse( + content={**data, **validate_wkt(data["parsed wkt"])}, status_code=200, - headers=constants.DEFAULT_HEADERS + headers=constants.DEFAULT_HEADERS, ) -@router.get('/services/utils/kml_footprint') -async def kml_to_footprint(granule: str, cmr_token: Optional[str] = None, maturity: str = 'prod'): + +@router.get("/services/utils/kml_footprint") +async def kml_to_footprint(granule: str, cmr_token: Optional[str] = None, maturity: str = "prod"): config = load_config_maturity(maturity=maturity) query_opts = asf.ASFSearchOptions(granule_list=[granule]) if (cmr_token) is not None: session = SearchAPISession() - session.headers.update({'Authorization': f'Bearer {cmr_token}'}) + session.headers.update({"Authorization": f"Bearer {cmr_token}"}) query_opts.session = session - - query_opts.host = config['cmr_base'] + query_opts.host = config["cmr_base"] results = asf.search(opts=query_opts, dataset=asf.DATASET.NISAR) - kml_file = results.find_urls(extension='.kml')[0] - + kml_file = results.find_urls(extension=".kml")[0] + kml_response = query_opts.session.get(kml_file) return Response( content=str(kml_response.text), status_code=200, - media_type='text/html; charset=utf-8', - headers=constants.DEFAULT_HEADERS + media_type="text/html; charset=utf-8", + headers=constants.DEFAULT_HEADERS, ) -@router.get('/services/utils/nisar_orbit_ephemera') + +@router.get("/services/utils/nisar_orbit_ephemera") async def get_nisar_orbit_ephemera(): """Returns the latest nisar orbit ephemera products for POE, MOE, NOE, and FOE in that order. Returns as jsonlite2""" try: oe_dict = asf.utils.get_nisar_orbit_ephemeras() results = ASFSearchResults([product for product in oe_dict.values()]) - response_info = as_output(results, 'jsonlite2') + response_info = as_output(results, "jsonlite2") return Response(**response_info) except (asf.ASFSearchError, asf.CMRError, ValueError) as exc: raise HTTPException( - detail=f"Search failed to find results: {exc}", - status_code=400 + detail=f"Search failed to find results: {exc}", status_code=400 ) from exc + # example: https://api.daac.asf.alaska.edu/services/redirect/NISAR_L2_STATIC/{granule_id}.h5 # @router.get('/services/redirect/{short_name}/{granule_id}') # async def nisar_static_layer(short_name: str, granule_id: str): @@ -289,7 +296,7 @@ async def get_nisar_orbit_ephemera(): # )[0] # except IndexError: # raise HTTPException(status_code=400, detail=f'Unable to find static layer, provided scene named "{granule_id}" not found in CMR record') - + # static_layer = granule.get_static_layer(opts=asf.ASFSearchOptions(shortName=short_name)) # if static_layer is None: # raise HTTPException(status_code=500, detail=f'Static layer not found for scene named "{granule_id}"') @@ -300,61 +307,57 @@ async def get_nisar_orbit_ephemera(): def validate_wkt(wkt: str): try: wrapped, unwrapped, reports = asf.validate_wkt(wkt) - repairs = [{'type': report.report_type, 'report': report.report} for report in reports if report.report_type != "'type': 'WRAP'"] + repairs = [ + {"type": report.report_type, "report": report.report} + for report in reports + if report.report_type != "'type': 'WRAP'" + ] except Exception as exc: raise HTTPException(detail=f"Failed to validate wkt {wkt}: {exc}", status_code=400) from exc - return { - 'wkt': { - 'unwrapped': unwrapped.wkt, - 'wrapped': wrapped.wkt - }, - 'repairs': repairs - } + return {"wkt": {"unwrapped": unwrapped.wkt, "wrapped": wrapped.wkt}, "repairs": repairs} + def _get_aria_baseline_stack(reference: str, opts: asf.ASFSearchOptions, output: str): - if output.lower() == 'count': - stack_opts = asf.Products.ARIAS1GUNWProduct.get_stack_opts_for_frame(int(reference), opts=opts) - count=asf.search_count(opts=stack_opts) + if output.lower() == "count": + stack_opts = asf.Products.ARIAS1GUNWProduct.get_stack_opts_for_frame( + int(reference), opts=opts + ) + count = asf.search_count(opts=stack_opts) return Response( content=str(count), status_code=200, - media_type='text/html; charset=utf-8', - headers=constants.DEFAULT_HEADERS + media_type="text/html; charset=utf-8", + headers=constants.DEFAULT_HEADERS, ) try: stack = asf.stack_from_id(reference, opts=opts) response_info = as_output(stack, output) return Response(**response_info) except (KeyError, IndexError, ValueError) as exc: - raise HTTPException(detail=f"Ran into an issue building stack for frame: {reference}\nException: {str(exc)}", status_code=400) from exc - + raise HTTPException( + detail=f"Ran into an issue building stack for frame: {reference}\nException: {str(exc)}", + status_code=400, + ) from exc -@router.get('/', response_class=JSONResponse) -@router.get('/health', response_class=JSONResponse) + +@router.get("/", response_class=JSONResponse) +@router.get("/health", response_class=JSONResponse) async def health_check(): try: version_path = os.path.join("SearchAPI", "version.json") - with open(version_path, 'r', encoding="utf-8") as version_file: + with open(version_path, "r", encoding="utf-8") as version_file: api_version = json.load(version_file) except Exception as exc: api_logger.info(exc) - api_version = {'version': 'unknown'} + api_version = {"version": "unknown"} api_health = { - 'ASFSearchAPI': { - 'ok?': True, - 'version': api_version['version'], - 'config': cfg - }, - 'CMRSearchAPI': cmr_health + "ASFSearchAPI": {"ok?": True, "version": api_version["version"], "config": cfg}, + "CMRSearchAPI": cmr_health, } - return JSONResponse( - content=api_health, - status_code=200, - headers=constants.DEFAULT_HEADERS - ) + return JSONResponse(content=api_health, status_code=200, headers=constants.DEFAULT_HEADERS) @app.exception_handler(HTTPException) @@ -366,9 +369,7 @@ async def handle_error(request: Request, error: HTTPException): } } return JSONResponse( - content=response, - status_code=error.status_code, - headers=constants.DEFAULT_HEADERS + content=response, status_code=error.status_code, headers=constants.DEFAULT_HEADERS ) diff --git a/src/SearchAPI/application/asf_opts.py b/src/SearchAPI/application/asf_opts.py index 8fd1397..a19cc71 100644 --- a/src/SearchAPI/application/asf_opts.py +++ b/src/SearchAPI/application/asf_opts.py @@ -13,23 +13,24 @@ from .SearchAPISession import SearchAPISession from .logger import api_logger -non_search_param = ['output', 'maxresults', 'pagesize', 'maturity'] +non_search_param = ["output", "maxresults", "pagesize", "maturity"] + def string_to_range(v: Union[str, list]) -> tuple: if isinstance(v, list): return v try: - v = v.replace(' ', '') - m = re.search(r'^(-?\d+(\.\d*)?)-(-?\d+(\.\d*)?)$', v) + v = v.replace(" ", "") + m = re.search(r"^(-?\d+(\.\d*)?)-(-?\d+(\.\d*)?)$", v) if m is None: - raise ValueError(f'Invalid range: {v}') + raise ValueError(f"Invalid range: {v}") a = (m.group(1), m.group(3)) if float(a[0]) > float(a[1]): raise ValueError() if a[0] == a[1]: a = a[0] except ValueError as exc: - raise ValueError(f'Invalid range: {exc}') from exc + raise ValueError(f"Invalid range: {exc}") from exc return a @@ -41,7 +42,7 @@ def string_to_list(v: Union[str, list[str]]) -> list: def parse_number_or_range(v: Union[str, list]): - m = re.search(r'^(-?\d+(\.\d*)?)$', v) + m = re.search(r"^(-?\d+(\.\d*)?)$", v) # If it's a digit: if m: return v @@ -60,21 +61,20 @@ def string_to_num_or_range_list(v: Union[str, list]): string_to_obj_map = { # Range only: - asf.validators.parse_date_range: string_to_range, - asf.validators.parse_int_range: string_to_range, - asf.validators.parse_float_range: string_to_range, + asf.validators.parse_date_range: string_to_range, + asf.validators.parse_int_range: string_to_range, + asf.validators.parse_float_range: string_to_range, # List only: - asf.validators.parse_string_list: string_to_list, - asf.validators.parse_int_list: string_to_list, - asf.validators.parse_float_list: string_to_list, - asf.validators.parse_circle: string_to_list, - asf.validators.parse_linestring: string_to_list, - asf.validators.parse_point: string_to_list, - asf.validators.parse_bbox: string_to_list, - + asf.validators.parse_string_list: string_to_list, + asf.validators.parse_int_list: string_to_list, + asf.validators.parse_float_list: string_to_list, + asf.validators.parse_circle: string_to_list, + asf.validators.parse_linestring: string_to_list, + asf.validators.parse_point: string_to_list, + asf.validators.parse_bbox: string_to_list, # Number or Range-list: - asf.validators.parse_int_or_range_list: string_to_num_or_range_list, - asf.validators.parse_float_or_range_list: string_to_num_or_range_list, + asf.validators.parse_int_or_range_list: string_to_num_or_range_list, + asf.validators.parse_float_or_range_list: string_to_num_or_range_list, } @@ -87,17 +87,10 @@ class ValidatorMap(collections.UserDict): >>> v["mAxReSuLtS"] # Pointer to validator method >>> v.actual_key_case("mAxReSuLtS") # "maxResults", what asf_search expects """ - ALIASED_KEYWORDS = { - 'collectionname': 'campaign' - } - FLIGHT_DIRECTIONS = { - 'A': 'ASCENDING', - 'D': 'DESCENDING' - } - LOOK_DIRECTIONS = { - 'R': 'R', - 'L': 'L' - } + + ALIASED_KEYWORDS = {"collectionname": "campaign"} + FLIGHT_DIRECTIONS = {"A": "ASCENDING", "D": "DESCENDING"} + LOOK_DIRECTIONS = {"R": "R", "L": "L"} """ legacy SearchAPI keys that have new names in asf-search """ @@ -122,23 +115,22 @@ def actual_key_case(self, k): return self.lower_lookup[k.lower()] def alias_params(self, params: dict) -> dict: - return { - self.ALIASED_KEYWORDS.get(k.lower(), k): v - for k, v in params.items() - } + return {self.ALIASED_KEYWORDS.get(k.lower(), k): v for k, v in params.items()} async def get_body(request: Request): """ Can remove when ASFSearchOptions uses Pydantic Model """ - if (content_type := request.headers.get('content-type')) is not None: + if (content_type := request.headers.get("content-type")) is not None: try: api_logger.debug(f"Request received, content-type header: {content_type})") - if content_type == 'application/json': + if content_type == "application/json": data = await request.json() return data - elif content_type == 'application/x-www-form-urlencoded' or content_type.startswith('multipart/form-data'): + elif content_type == "application/x-www-form-urlencoded" or content_type.startswith( + "multipart/form-data" + ): data = await request.form() return dict(data) except Exception as exc: @@ -165,42 +157,44 @@ async def process_search_request(request: Request, is_baseline: bool = False) -> merged_args = {**query_params, **body} - if (token := merged_args.get('cmr_token')): + if token := merged_args.get("cmr_token"): session = SearchAPISession() - session.headers.update({'Authorization': 'Bearer {0}'.format(token)}) + session.headers.update({"Authorization": "Bearer {0}".format(token)}) query_opts.session = session - - if (cmr_provider := merged_args.get('cmr_provider')): - query_opts.provider=cmr_provider - output = merged_args.get('output', 'metalink') - maturity = merged_args.get('maturity', 'prod') + if cmr_provider := merged_args.get("cmr_provider"): + query_opts.provider = cmr_provider + + output = merged_args.get("output", "metalink") + maturity = merged_args.get("maturity", "prod") config = load_config_maturity(maturity=maturity) - query_opts.host = config['cmr_base'] + query_opts.host = config["cmr_base"] try: # we are no longer allowing unbounded searches if ( - query_opts.granule_list is None - and query_opts.product_list is None - and output not in ['python', 'count'] + query_opts.granule_list is None + and query_opts.product_list is None + and output not in ["python", "count"] and not is_baseline - ): + ): if query_opts.maxResults is None: maxResults = asf.search_count(opts=query_opts) if maxResults > 2000: raise ValueError( ( - 'SearchAPI no longer supports unbounded searches with expected results over 2000, ' - 'please use the asf-search python module for long-lived searches or set `maxResults` to 2000 or less. ' - 'To have SearchAPI automatically generate a python script for the equivalent search to your SearchAPI query ' - 'set `output=python`' + "SearchAPI no longer supports unbounded searches with expected results over 2000, " + "please use the asf-search python module for long-lived searches or set `maxResults` to 2000 or less. " + "To have SearchAPI automatically generate a python script for the equivalent search to your SearchAPI query " + "set `output=python`" ) ) elif query_opts.maxResults <= 0: raise ValueError('Search keyword "maxResults" must be greater than 0') - searchOpts = SearchOptsModel(opts=query_opts, output=output, merged_args=merged_args, request_method=request.method) + searchOpts = SearchOptsModel( + opts=query_opts, output=output, merged_args=merged_args, request_method=request.method + ) except (ValueError, ValidationError) as exc: raise HTTPException(detail=repr(exc), status_code=400) from exc @@ -210,7 +204,7 @@ async def process_search_request(request: Request, is_baseline: bool = False) -> async def process_baseline_request(request: Request) -> BaselineSearchOptsModel: """Processes request to baseline endpoint""" searchOpts = await process_search_request(request=request, is_baseline=True) - reference = searchOpts.merged_args.get('reference') + reference = searchOpts.merged_args.get("reference") try: baselineSearchOpts = BaselineSearchOptsModel(**searchOpts.model_dump(), reference=reference) except (ValueError, ValidationError) as exc: @@ -218,32 +212,36 @@ async def process_baseline_request(request: Request) -> BaselineSearchOptsModel: return baselineSearchOpts + async def process_wkt_request(request: Request) -> str: """ Extracts the request's query+body params, returns wkt string """ query_params = dict(request.query_params) - wkt = query_params.get('wkt') + wkt = query_params.get("wkt") body = await get_body(request) - body_wkt = body.get('wkt') + body_wkt = body.get("wkt") if wkt is None: wkt = body_wkt if wkt is None: - raise HTTPException(500, 'Validation Error: `wkt` string required') + raise HTTPException(500, "Validation Error: `wkt` string required") return wkt + def get_asf_opts(params: dict) -> asf.ASFSearchOptions: """ Parses ASFSearchOptions from a dictionary """ try: for param, value in params.items(): - if value is None or ((isinstance(value, str) or isinstance(value, list)) and len(value) == 0): + if value is None or ( + (isinstance(value, str) or isinstance(value, list)) and len(value) == 0 + ): raise ValueError(f'Empty value passed to search keyword "{param}"') except ValueError as exc: raise HTTPException(detail=repr(exc), status_code=400) from exc @@ -253,10 +251,7 @@ def get_asf_opts(params: dict) -> asf.ASFSearchOptions: params = validatorMap.alias_params(params) # You have to rebuild the dict, since you can't change keys in place params = { - validatorMap.actual_key_case(k) - if k in validatorMap - else k: v - for k, v in params.items() + validatorMap.actual_key_case(k) if k in validatorMap else k: v for k, v in params.items() } # De-stringify the values if we know how: @@ -272,24 +267,57 @@ def get_asf_opts(params: dict) -> asf.ASFSearchOptions: # SearchOpts doesn't know how to handle these keys, but other methods need them # (We still want to throw on any UNKNOWN keys) - ignore_keys_lower = ["output", "reference", "maturity", "cmr_keywords", "cmr_token", "cmr_provider"] + ignore_keys_lower = [ + "output", + "reference", + "maturity", + "cmr_keywords", + "cmr_token", + "cmr_provider", + ] + + output = params.get("output", "").lower() params = {k: params[k] for k in params.keys() if k.lower() not in ignore_keys_lower} try: if "granule_list" in params or "product_list" in params: + if "granule_list" in params: + if ( + any("*" in granule for granule in params.get("granule_list", [])) + and "maxResults" not in params + and output + not in [ + "python", + "count", + ] + ): + raise ValueError( + "Unbound wildcard searches not supported with SearchAPI." + "Specify `maxresults` or use the asf-search python module directly (try `output=python` to download the equivalent script)" + ) if len([param for param in params if param not in ["collections", "maxResults"]]) > 1: - raise ValueError(f'Cannot use search keywords "granule_list/product_list" with other search params') + raise ValueError( + 'Cannot use search keywords "granule_list/product_list" with other search params' + ) - if (flight_direction := params.get('flightDirection')) is not None: + if (flight_direction := params.get("flightDirection")) is not None: if isinstance(flight_direction, str) and len(flight_direction): - params['flightDirection'] = ValidatorMap.FLIGHT_DIRECTIONS.get(flight_direction.upper()[0], None) - if params['flightDirection'] is None: - raise ValueError(f'Invalid value passed to search keyword "flightDirection": "{flight_direction}". Valid directions are "ASCENDING" or "DESCENDING"') - if (lookDirection := params.get('lookDirection')) is not None: + params["flightDirection"] = ValidatorMap.FLIGHT_DIRECTIONS.get( + flight_direction.upper()[0], None + ) + if params["flightDirection"] is None: + raise ValueError( + f'Invalid value passed to search keyword "flightDirection": "{flight_direction}". Valid directions are "ASCENDING" or "DESCENDING"' + ) + if (lookDirection := params.get("lookDirection")) is not None: if isinstance(lookDirection, str) and len(lookDirection): - params['lookDirection'] = ValidatorMap.LOOK_DIRECTIONS.get(lookDirection.upper()[0], None) - if params['lookDirection'] is None: - raise ValueError(f'Invalid value passed to search keyword "lookDirection": "{lookDirection}". Valid directions are "R" or "L"') + params["lookDirection"] = ValidatorMap.LOOK_DIRECTIONS.get( + lookDirection.upper()[0], None + ) + if params["lookDirection"] is None: + raise ValueError( + f'Invalid value passed to search keyword "lookDirection": "{lookDirection}". Valid directions are "R" or "L"' + ) except ValueError as exc: raise HTTPException(detail=repr(exc), status_code=400) from exc diff --git a/src/SearchAPI/application/output.py b/src/SearchAPI/application/output.py index fdd6b3b..6afc33c 100644 --- a/src/SearchAPI/application/output.py +++ b/src/SearchAPI/application/output.py @@ -10,7 +10,7 @@ from . import constants from . import asf_env -asf_search_script_template = '''## This script requires that the asf-search python module is installed +asf_search_script_template = """## This script requires that the asf-search python module is installed ## to install, run the following in a terminal ## `pip install asf-search` ## Then from the correct folder in your terminal run: @@ -34,9 +34,9 @@ results=asf.search(opts=opts) pprint.pp(results.geojson()) -''' +""" -asf_search_baseline_script_template= '''## This script requires that the asf-search python module is installed +asf_search_baseline_script_template = """## This script requires that the asf-search python module is installed ## to install, run the following in a terminal ## `pip install asf-search` ## Then from the correct folder in your terminal run: @@ -52,7 +52,7 @@ ## if the search requires authentication, uncomment ## the lines below, and enter your EDL credentials when prompted ## (use `session.auth_with_token(getpass('EDL Token'))` instead if a CMR bearer token is required) -# from get_pass import get_pass +# from getpass import getpass # session=asf.ASFSession() # session.auth_with_creds(input('EDL Username'), getpass('EDL Password')) # opts.session = session @@ -62,98 +62,98 @@ pprint.pp(stack.geojson()) -''' +""" + def as_output(results: asf.ASFSearchResults, output: str) -> dict: output_format = output.lower() # Use a switch statement, so you only load the type of output you need: match output_format: - case 'json': + case "json": return { - 'content': ''.join(results.json()), - 'media_type': 'application/json; charset=utf-8', - 'headers': { + "content": "".join(results.json()), + "media_type": "application/json; charset=utf-8", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={make_filename('json')}", - } + "Content-Disposition": f"attachment; filename={make_filename('json')}", + }, } - case 'jsonlite': + case "jsonlite": return { - 'content': ''.join(results.jsonlite()), - 'media_type': 'application/json; charset=utf-8', - 'headers': { + "content": "".join(results.jsonlite()), + "media_type": "application/json; charset=utf-8", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={make_filename('json')}", - } + "Content-Disposition": f"attachment; filename={make_filename('json')}", + }, } - case 'jsonlite2': + case "jsonlite2": return { - 'content': ''.join(results.jsonlite2()), - 'media_type': 'application/json; charset=utf-8', - 'headers': { + "content": "".join(results.jsonlite2()), + "media_type": "application/json; charset=utf-8", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={make_filename('json')}", - } + "Content-Disposition": f"attachment; filename={make_filename('json')}", + }, } - case 'geojson': + case "geojson": return { - 'content': json.dumps(results.geojson(), indent=4), - 'media_type': 'application/geo+json; charset=utf-8', - 'headers': { + "content": json.dumps(results.geojson(), indent=4), + "media_type": "application/geo+json; charset=utf-8", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={make_filename('geojson')}", - } + "Content-Disposition": f"attachment; filename={make_filename('geojson')}", + }, } - case 'csv': + case "csv": return { - 'content': ''.join(results.csv()), - 'media_type': 'text/csv; charset=utf-8', - 'headers': { + "content": "".join(results.csv()), + "media_type": "text/csv; charset=utf-8", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={make_filename('csv')}", - } + "Content-Disposition": f"attachment; filename={make_filename('csv')}", + }, } - case 'kml': + case "kml": return { - 'content': ''.join(results.kml()), - 'media_type': 'application/vnd.google-earth.kml+xml; charset=utf-8', - 'headers': { + "content": "".join(results.kml()), + "media_type": "application/vnd.google-earth.kml+xml; charset=utf-8", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={make_filename('kml')}", - } + "Content-Disposition": f"attachment; filename={make_filename('kml')}", + }, } - case 'metalink': + case "metalink": return { - 'content': ''.join(results.metalink()), - 'media_type': 'application/metalink+xml; charset=utf-8', - 'headers': { + "content": "".join(results.metalink()), + "media_type": "application/metalink+xml; charset=utf-8", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={make_filename('metalink')}", - } + "Content-Disposition": f"attachment; filename={make_filename('metalink')}", + }, } - case 'download': + case "download": # Only call this once to guarantee the names always are the same: - filename = make_filename('py') + filename = make_filename("py") return { - 'content': get_download(results, filename=filename), - 'media_type': 'text/x-python', - 'headers': { + "content": get_download(results, filename=filename), + "media_type": "text/x-python", + "headers": { **constants.DEFAULT_HEADERS, - 'Content-Disposition': f"attachment; filename={filename}", - } + "Content-Disposition": f"attachment; filename={filename}", + }, } # The default case. Throw if you get this far: case _: raise HTTPException( - detail=f"Unknown output '{output_format}' was requested.", - status_code=400 + detail=f"Unknown output '{output_format}' was requested.", status_code=400 ) def get_download(results: asf.ASFSearchResults, filename=None): # Load basic consts: - script_url = asf_env.load_config_maturity()['bulk_download_api'] + script_url = asf_env.load_config_maturity()["bulk_download_api"] file_type = asf.FileDownloadType.DEFAULT_FILE # Build the url list: url_list = [] @@ -161,29 +161,31 @@ def get_download(results: asf.ASFSearchResults, filename=None): url_list.extend(product.get_urls(fileType=file_type)) # Setup the data you're posting with. Optional filename so it lines up with our headers: - script_data = {'products': ','.join(url_list)} + script_data = {"products": ",".join(url_list)} if filename: - script_data['filename'] = filename + script_data["filename"] = filename # Finally make the request: script_request = requests.post(script_url, data=script_data, timeout=30) return script_request.text + def get_asf_search_script( - opts: asf.ASFSearchOptions, - reference: str = None, - search_endpoint: Literal['param', 'baseline'] = 'param' - ) -> tuple[str, str]: - + opts: asf.ASFSearchOptions, + reference: str = None, + search_endpoint: Literal["param", "baseline"] = "param", +) -> tuple[str, str]: + opts.session = None # ASFSearchOptions formatting uses json.dumps for serialization. Add proper python capitalization - opts_str = str(opts).replace('true', 'True', -1).replace('false', 'False') - if search_endpoint == 'param': - file_name=make_filename('py', prefix='asf-search-script') + opts_str = str(opts).replace("true", "True", -1).replace("false", "False") + if search_endpoint == "param": + file_name = make_filename("py", prefix="asf-search-script") output_script = asf_search_script_template.format(file_name, opts_str) else: - file_name=make_filename('py', prefix='asf-search-baseline-script') + file_name = make_filename("py", prefix="asf-search-baseline-script") output_script = asf_search_baseline_script_template.format(file_name, reference, opts_str) return file_name, output_script -def make_filename(suffix, prefix:str = 'asf-results'): - return f'{prefix}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.{suffix}' + +def make_filename(suffix, prefix: str = "asf-results"): + return f"{prefix}-{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.{suffix}"