├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── cmr ├── __init__.py └── queries.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── test_collection.py └── test_granule.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .idea 3 | *.pyc 4 | tags -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "3.6" 7 | install: "pip install ." 8 | script: "python -m unittest discover" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Justin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Deprecation Notice 2 | ================== 3 | This repo is no longer maintained. The last release published to PyPI from this repo was v0.4.1. 4 | 5 | For the latest development, issues, and pull requests, please see the projects new home: `NASA's python-cmr `_. 6 | 7 | Python CMR 8 | ========== 9 | 10 | .. image:: https://travis-ci.org/jddeal/python-cmr.svg?branch=master 11 | :target: https://travis-ci.org/jddeal/python-cmr 12 | 13 | Python CMR is an easy to use wrapper to the NASA EOSDIS 14 | `Common Metadata Repository API `_. This package aims to make 15 | querying the API intuitive and less error-prone by providing methods that will preemptively check 16 | for invalid input and handle the URL encoding the CMR API expects. 17 | 18 | Getting access to NASA's earth science metadata is as simple as this: 19 | 20 | :: 21 | 22 | >>> from cmr import CollectionQuery, GranuleQuery 23 | 24 | >>> api = CollectionQuery() 25 | >>> collections = api.archive_center("LP DAAC").keyword("AST_L1*").get(5) 26 | 27 | >>> for collection in collections: 28 | >>> print(collection["short_name"]) 29 | AST_L1A 30 | AST_L1AE 31 | AST_L1T 32 | 33 | >>> api = GranuleQuery() 34 | >>> granules = api.short_name("AST_L1T").point(-112.73, 42.5).get(3) 35 | 36 | >>> for granule in granules: 37 | >>> print(granule["title"]) 38 | SC:AST_L1T.003:2149105822 39 | SC:AST_L1T.003:2149105820 40 | SC:AST_L1T.003:2149155037 41 | 42 | 43 | Installation 44 | ============ 45 | 46 | To install from pypi: 47 | 48 | :: 49 | 50 | $ pip install python-cmr 51 | 52 | 53 | To install from github, perhaps to try out the dev branch: 54 | 55 | :: 56 | 57 | $ git clone https://github.com/jddeal/python-cmr 58 | $ cd python-cmr 59 | $ pip install . 60 | 61 | 62 | Examples 63 | ======== 64 | 65 | This library is broken into two classes, `CollectionQuery` and `GranuleQuery`. Each of these 66 | classes provide a large set of methods used to build a query for CMR. Not all parameters provided 67 | by the CMR API are covered by this version of python-cmr. 68 | 69 | The following methods are available to both collecton and granule queries: 70 | 71 | :: 72 | 73 | # search for granules matching a specific product/short_name 74 | >>> api.short_name("AST_L1T") 75 | 76 | # search for granules matching a specific version 77 | >>> api.version("006") 78 | 79 | # search for granules at a specific longitude and latitude 80 | >>> api.point(-112.73, 42.5) 81 | 82 | # search for granules in an area bound by a box (lower left lon/lat, upper right lon/lat) 83 | >>> api.bounding_box(-112.70, 42.5, -110, 44.5) 84 | 85 | # search for granules in a polygon (these need to be in counter clockwise order and the 86 | # last coordinate must match the first in order to close the polygon) 87 | >>> api.polygon([(-100, 40), (-110, 40), (-105, 38), (-100, 40)]) 88 | 89 | # search for granules in a line 90 | >>> api.line([(-100, 40), (-90, 40), (-95, 38)]) 91 | 92 | # search for granules in an open or closed date range 93 | >>> api.temporal("2016-10-10T01:02:00Z", "2016-10-12T00:00:30Z") 94 | >>> api.temporal("2016-10-10T01:02:00Z", None) 95 | >>> api.temporal(datetime(2016, 10, 10, 1, 2, 0), datetime.now()) 96 | 97 | # only include granules available for download 98 | >>> api.downloadable() 99 | 100 | # only include granules that are unavailable for download 101 | >>> api.online_only() 102 | 103 | # search for collections/granules associated with or identified by concept IDs 104 | # note: often the ECHO collection ID can be used here as well 105 | # note: when using CollectionQuery, only collection concept IDs can be passed 106 | # note: when uses GranuleQuery, passing a collection's concept ID will filter by granules associated 107 | # with that particular collection. 108 | >>> api.concept_id("C1299783579-LPDAAC_ECS") 109 | >>> api.concept_id(["G1327299284-LPDAAC_ECS", "G1326330014-LPDAAC_ECS"]) 110 | 111 | 112 | Granule searches support these methods (in addition to the shared methods above): 113 | 114 | :: 115 | 116 | # search for a granule by its unique ID 117 | >>> api.granule_ur("SC:AST_L1T.003:2150315169") 118 | # search for granules from a specific orbit 119 | >>> api.orbit_number(5000) 120 | 121 | # filter by the day/night flag 122 | >>> api.day_night_flag("day") 123 | 124 | # filter by cloud cover percentage range 125 | >>> api.cloud_cover(25, 75) 126 | 127 | # filter by specific instrument or platform 128 | >>> api.instrument("MODIS") 129 | >>> api.platform("Terra") 130 | 131 | 132 | Collection searches support these methods (in addition to the shared methods above): 133 | 134 | :: 135 | 136 | # search for collections from a specific archive center 137 | >>> api.archive_center("LP DAAC") 138 | 139 | # case insensitive, wildcard enabled text search through most collection fields 140 | >>> api.keyword("M*D09") 141 | 142 | 143 | As an alternative to chaining methods together to set the parameters of your query, a 144 | method exists to allow you to pass your parameters as keyword arguments: 145 | 146 | :: 147 | 148 | # search for AST_L1T version 003 granules at latitude 42, longitude -100 149 | >>> api.parameters( 150 | short_name="AST_L1T", 151 | version="003", 152 | point=(-100, 42) 153 | ) 154 | 155 | Note: the kwarg key should match the name of a method from the above examples, and the value 156 | should be a tuple if it's a parameter that requires multiple values. 157 | 158 | 159 | To inspect and retreive results from the API, the following methods are available: 160 | 161 | :: 162 | 163 | # inspect the number of results the query will return without downloading the results 164 | >>> print(api.hits()) 165 | 166 | # retrieve 100 granules 167 | >>> granules = api.get(100) 168 | 169 | # retrieve 25,000 granules 170 | >>> granules = api.get(25000) 171 | 172 | # retrieve all the granules possible for the query 173 | >>> granules = api.get_all() # this is a shortcut for api.get(api.hits()) 174 | 175 | 176 | By default the responses will return as json and be accessible as a list of python dictionaries. 177 | Other formats can be specified before making the request: 178 | 179 | :: 180 | 181 | >>> granules = api.format("echo10").get(100) 182 | 183 | The following formats are supported for both granule and collection queries: 184 | 185 | * json (default) 186 | * xml 187 | * echo10 188 | * iso 189 | * iso19115 190 | * csv 191 | * atom 192 | * kml 193 | * native 194 | 195 | Collection queries also support the following formats: 196 | 197 | * dif 198 | * dif10 199 | * opendata 200 | * umm_json 201 | * umm_json_vX_Y (ex: umm_json_v1_9) 202 | -------------------------------------------------------------------------------- /cmr/__init__.py: -------------------------------------------------------------------------------- 1 | from .queries import GranuleQuery, CollectionQuery 2 | 3 | __all__ = ["GranuleQuery", "CollectionQuery"] 4 | -------------------------------------------------------------------------------- /cmr/queries.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains all CMR query types. 3 | """ 4 | 5 | try: 6 | from urllib.parse import quote 7 | except ImportError: 8 | from urllib import pathname2url as quote 9 | 10 | from datetime import datetime 11 | from inspect import getmembers, ismethod 12 | from re import search 13 | from requests import get, exceptions 14 | 15 | CMR_OPS = "https://cmr.earthdata.nasa.gov/search/" 16 | CMR_UAT = "https://cmr.uat.earthdata.nasa.gov/search/" 17 | CMR_SIT = "https://cmr.sit.earthdata.nasa.gov/search/" 18 | 19 | class Query(object): 20 | """ 21 | Base class for all CMR queries. 22 | """ 23 | 24 | _base_url = "" 25 | _route = "" 26 | _format = "json" 27 | _valid_formats_regex = [ 28 | "json", "xml", "echo10", "iso", "iso19115", 29 | "csv", "atom", "kml", "native" 30 | ] 31 | 32 | def __init__(self, route, mode=CMR_OPS): 33 | self.params = {} 34 | self.options = {} 35 | self._route = route 36 | self.mode(mode) 37 | 38 | def get(self, limit=2000): 39 | """ 40 | Get all results up to some limit, even if spanning multiple pages. 41 | 42 | :limit: The number of results to return 43 | :returns: query results as a list 44 | """ 45 | 46 | page_size = min(limit, 2000) 47 | url = self._build_url() 48 | 49 | results = [] 50 | page = 1 51 | while len(results) < limit: 52 | 53 | response = get(url, params={'page_size': page_size, 'page_num': page}) 54 | 55 | try: 56 | response.raise_for_status() 57 | except exceptions.HTTPError as ex: 58 | raise RuntimeError(ex.response.text) 59 | 60 | if self._format == "json": 61 | latest = response.json()['feed']['entry'] 62 | else: 63 | latest = [response.text] 64 | 65 | if len(latest) == 0: 66 | break 67 | 68 | results.extend(latest) 69 | page += 1 70 | 71 | return results 72 | 73 | def hits(self): 74 | """ 75 | Returns the number of hits the current query will return. This is done by 76 | making a lightweight query to CMR and inspecting the returned headers. 77 | 78 | :returns: number of results reproted by CMR 79 | """ 80 | 81 | url = self._build_url() 82 | 83 | response = get(url, params={'page_size': 0}) 84 | 85 | try: 86 | response.raise_for_status() 87 | except exceptions.HTTPError as ex: 88 | raise RuntimeError(ex.response.text) 89 | 90 | return int(response.headers["CMR-Hits"]) 91 | 92 | def get_all(self): 93 | """ 94 | Returns all of the results for the query. This will call hits() first to determine how many 95 | results their are, and then calls get() with that number. This method could take quite 96 | awhile if many requests have to be made. 97 | 98 | :returns: query results as a list 99 | """ 100 | 101 | return self.get(self.hits()) 102 | 103 | def parameters(self, **kwargs): 104 | """ 105 | Provide query parameters as keyword arguments. The keyword needs to match the name 106 | of the method, and the value should either be the value or a tuple of values. 107 | 108 | Example: parameters(short_name="AST_L1T", point=(42.5, -101.25)) 109 | 110 | :returns: Query instance 111 | """ 112 | 113 | # build a dictionary of method names and their reference 114 | methods = {} 115 | for name, func in getmembers(self, predicate=ismethod): 116 | methods[name] = func 117 | 118 | for key, val in kwargs.items(): 119 | 120 | # verify the key matches one of our methods 121 | if key not in methods: 122 | raise ValueError("Unknown key {}".format(key)) 123 | 124 | # call the method 125 | if isinstance(val, tuple): 126 | methods[key](*val) 127 | else: 128 | methods[key](val) 129 | 130 | return self 131 | 132 | def format(self, output_format="json"): 133 | """ 134 | Sets the format for the returned results. 135 | 136 | :param output_format: Preferred output format 137 | :returns: Query instance 138 | """ 139 | 140 | if not output_format: 141 | output_format = "json" 142 | 143 | # check requested format against the valid format regex's 144 | for _format in self._valid_formats_regex: 145 | if search(_format, output_format): 146 | self._format = output_format 147 | return self 148 | 149 | # if we got here, we didn't find a matching format 150 | raise ValueError("Unsupported format '{}'".format(output_format)) 151 | 152 | def online_only(self, online_only=True): 153 | """ 154 | Only match granules that are listed online and not available for download. 155 | The opposite of this method is downloadable(). 156 | 157 | :param online_only: True to require granules only be online 158 | :returns: Query instance 159 | """ 160 | 161 | if not isinstance(online_only, bool): 162 | raise TypeError("Online_only must be of type bool") 163 | 164 | # remove the inverse flag so CMR doesn't crash 165 | if "downloadable" in self.params: 166 | del self.params["downloadable"] 167 | 168 | self.params['online_only'] = online_only 169 | 170 | return self 171 | 172 | def temporal(self, date_from, date_to, exclude_boundary=False): 173 | """ 174 | Filter by an open or closed date range. 175 | 176 | Dates can be provided as a datetime objects or ISO 8601 formatted strings. Multiple 177 | ranges can be provided by successive calls to this method before calling execute(). 178 | 179 | :param date_from: earliest date of temporal range 180 | :param date_to: latest date of temporal range 181 | :param exclude_boundary: whether or not to exclude the date_from/to in the matched range 182 | :returns: GranueQuery instance 183 | """ 184 | 185 | iso_8601 = "%Y-%m-%dT%H:%M:%SZ" 186 | 187 | # process each date into a datetime object 188 | def convert_to_string(date): 189 | """ 190 | Returns the argument as an ISO 8601 or empty string. 191 | """ 192 | 193 | if not date: 194 | return "" 195 | 196 | try: 197 | # see if it's datetime-like 198 | return date.strftime(iso_8601) 199 | except AttributeError: 200 | try: 201 | # maybe it already is an ISO 8601 string 202 | datetime.strptime(date, iso_8601) 203 | return date 204 | except TypeError: 205 | raise ValueError( 206 | "Please provide None, datetime objects, or ISO 8601 formatted strings." 207 | ) 208 | 209 | date_from = convert_to_string(date_from) 210 | date_to = convert_to_string(date_to) 211 | 212 | # if we have both dates, make sure from isn't later than to 213 | if date_from and date_to: 214 | if date_from > date_to: 215 | raise ValueError("date_from must be earlier than date_to.") 216 | 217 | # good to go, make sure we have a param list 218 | if "temporal" not in self.params: 219 | self.params["temporal"] = [] 220 | 221 | self.params["temporal"].append("{},{}".format(date_from, date_to)) 222 | 223 | if exclude_boundary: 224 | self.options["temporal"] = { 225 | "exclude_boundary": True 226 | } 227 | 228 | return self 229 | 230 | def short_name(self, short_name): 231 | """ 232 | Filter by short name (aka product or collection name). 233 | 234 | :param short_name: name of collection 235 | :returns: Query instance 236 | """ 237 | 238 | if not short_name: 239 | return self 240 | 241 | self.params['short_name'] = short_name 242 | return self 243 | 244 | def version(self, version): 245 | """ 246 | Filter by version. Note that CMR defines this as a string. For example, 247 | MODIS version 6 products must be searched for with "006". 248 | 249 | :param version: version string 250 | :returns: Query instance 251 | """ 252 | 253 | if not version: 254 | return self 255 | 256 | self.params['version'] = version 257 | return self 258 | 259 | def point(self, lon, lat): 260 | """ 261 | Filter by granules that include a geographic point. 262 | 263 | :param lon: longitude of geographic point 264 | :param lat: latitude of geographic point 265 | :returns: Query instance 266 | """ 267 | 268 | if not lat or not lon: 269 | return self 270 | 271 | # coordinates must be a float 272 | lon = float(lon) 273 | lat = float(lat) 274 | 275 | self.params['point'] = "{},{}".format(lon, lat) 276 | 277 | return self 278 | 279 | def polygon(self, coordinates): 280 | """ 281 | Filter by granules that overlap a polygonal area. Must be used in combination with a 282 | collection filtering parameter such as short_name or entry_title. 283 | 284 | :param coordinates: list of (lon, lat) tuples 285 | :returns: Query instance 286 | """ 287 | 288 | if not coordinates: 289 | return self 290 | 291 | # make sure we were passed something iterable 292 | try: 293 | iter(coordinates) 294 | except TypeError: 295 | raise ValueError("A line must be an iterable of coordinate tuples. Ex: [(90,90), (91, 90), ...]") 296 | 297 | # polygon requires at least 4 pairs of coordinates 298 | if len(coordinates) < 4: 299 | raise ValueError("A polygon requires at least 4 pairs of coordinates.") 300 | 301 | # convert to floats 302 | as_floats = [] 303 | for lon, lat in coordinates: 304 | as_floats.extend([float(lon), float(lat)]) 305 | 306 | # last point must match first point to complete polygon 307 | if as_floats[0] != as_floats[-2] or as_floats[1] != as_floats[-1]: 308 | raise ValueError("Coordinates of the last pair must match the first pair.") 309 | 310 | # convert to strings 311 | as_strs = [str(val) for val in as_floats] 312 | 313 | self.params["polygon"] = ",".join(as_strs) 314 | 315 | return self 316 | 317 | def bounding_box(self, lower_left_lon, lower_left_lat, upper_right_lon, upper_right_lat): 318 | """ 319 | Filter by granules that overlap a bounding box. Must be used in combination with 320 | a collection filtering parameter such as short_name or entry_title. 321 | 322 | :param lower_left_lon: lower left longitude of the box 323 | :param lower_left_lat: lower left latitude of the box 324 | :param upper_right_lon: upper right longitude of the box 325 | :param upper_right_lat: upper right latitude of the box 326 | :returns: Query instance 327 | """ 328 | 329 | self.params["bounding_box"] = "{},{},{},{}".format( 330 | float(lower_left_lon), 331 | float(lower_left_lat), 332 | float(upper_right_lon), 333 | float(upper_right_lat) 334 | ) 335 | 336 | return self 337 | 338 | def line(self, coordinates): 339 | """ 340 | Filter by granules that overlap a series of connected points. Must be used in combination 341 | with a collection filtering parameter such as short_name or entry_title. 342 | 343 | :param coordinates: a list of (lon, lat) tuples 344 | :returns: Query instance 345 | """ 346 | 347 | if not coordinates: 348 | return self 349 | 350 | # make sure we were passed something iterable 351 | try: 352 | iter(coordinates) 353 | except TypeError: 354 | raise ValueError("A line must be an iterable of coordinate tuples. Ex: [(90,90), (91, 90), ...]") 355 | 356 | # need at least 2 pairs of coordinates 357 | if len(coordinates) < 2: 358 | raise ValueError("A line requires at least 2 pairs of coordinates.") 359 | 360 | # make sure they're all floats 361 | as_floats = [] 362 | for lon, lat in coordinates: 363 | as_floats.extend([float(lon), float(lat)]) 364 | 365 | # cast back to string for join 366 | as_strs = [str(val) for val in as_floats] 367 | 368 | self.params["line"] = ",".join(as_strs) 369 | 370 | return self 371 | 372 | def downloadable(self, downloadable=True): 373 | """ 374 | Only match granules that are available for download. The opposite of this 375 | method is online_only(). 376 | 377 | :param downloadable: True to require granules be downloadable 378 | :returns: Query instance 379 | """ 380 | 381 | if not isinstance(downloadable, bool): 382 | raise TypeError("Downloadable must be of type bool") 383 | 384 | # remove the inverse flag so CMR doesn't crash 385 | if "online_only" in self.params: 386 | del self.params["online_only"] 387 | 388 | self.params['downloadable'] = downloadable 389 | 390 | return self 391 | 392 | def entry_title(self, entry_title): 393 | """ 394 | Filter by the collection entry title. 395 | 396 | :param entry_title: Entry title of the collection 397 | :returns: Query instance 398 | """ 399 | 400 | entry_title = quote(entry_title) 401 | 402 | self.params['entry_title'] = entry_title 403 | 404 | return self 405 | 406 | def _build_url(self): 407 | """ 408 | Builds the URL that will be used to query CMR. 409 | 410 | :returns: the url as a string 411 | """ 412 | 413 | # last chance validation for parameters 414 | if not self._valid_state(): 415 | raise RuntimeError(("Spatial parameters must be accompanied by a collection " 416 | "filter (ex: short_name or entry_title).")) 417 | 418 | # encode params 419 | formatted_params = [] 420 | for key, val in self.params.items(): 421 | 422 | # list params require slightly different formatting 423 | if isinstance(val, list): 424 | for list_val in val: 425 | formatted_params.append("{}[]={}".format(key, list_val)) 426 | elif isinstance(val, bool): 427 | formatted_params.append("{}={}".format(key, str(val).lower())) 428 | else: 429 | formatted_params.append("{}={}".format(key, val)) 430 | 431 | params_as_string = "&".join(formatted_params) 432 | 433 | # encode options 434 | formatted_options = [] 435 | for param_key in self.options: 436 | for option_key, val in self.options[param_key].items(): 437 | 438 | # all CMR options must be booleans 439 | if not isinstance(val, bool): 440 | raise ValueError("parameter '{}' with option '{}' must be a boolean".format( 441 | param_key, 442 | option_key 443 | )) 444 | 445 | formatted_options.append("options[{}][{}]={}".format( 446 | param_key, 447 | option_key, 448 | val 449 | )) 450 | 451 | options_as_string = "&".join(formatted_options) 452 | res = "{}.{}?{}&{}".format( 453 | self._base_url, 454 | self._format, 455 | params_as_string, 456 | options_as_string 457 | ) 458 | res = res.rstrip('&') 459 | return res 460 | 461 | def _valid_state(self): 462 | """ 463 | Determines if the Query is in a valid state based on the parameters and options 464 | that have been set. This should be implemented by the subclasses. 465 | 466 | :returns: True if the state is valid, otherwise False 467 | """ 468 | 469 | raise NotImplementedError() 470 | 471 | def mode(self, mode=CMR_OPS): 472 | """ 473 | Sets the mode of the api target to the given URL 474 | CMR_OPS, CMR_UAT, CMR_SIT are provided as class variables 475 | 476 | :param mode: Mode to set the query to target 477 | :throws: Will throw if provided None 478 | """ 479 | if mode is None: 480 | raise ValueError("Please provide a valid mode (CMR_OPS, CMR_UAT, CMR_SIT)") 481 | 482 | self._base_url = str(mode) + self._route 483 | 484 | class GranuleQuery(Query): 485 | """ 486 | Class for querying granules from the CMR. 487 | """ 488 | 489 | def __init__(self, mode=CMR_OPS): 490 | Query.__init__(self, "granules", mode) 491 | 492 | def orbit_number(self, orbit1, orbit2=None): 493 | """" 494 | Filter by the orbit number the granule was acquired during. Either a single 495 | orbit can be targeted or a range of orbits. 496 | 497 | :param orbit1: orbit to target (lower limit of range when orbit2 is provided) 498 | :param orbit2: upper limit of range 499 | :returns: Query instance 500 | """ 501 | 502 | if orbit2: 503 | self.params['orbit_number'] = quote('{},{}'.format(str(orbit1), str(orbit2))) 504 | else: 505 | self.params['orbit_number'] = orbit1 506 | 507 | return self 508 | 509 | def day_night_flag(self, day_night_flag): 510 | """ 511 | Filter by period of the day the granule was collected during. 512 | 513 | :param day_night_flag: "day", "night", or "unspecified" 514 | :returns: Query instance 515 | """ 516 | 517 | if not isinstance(day_night_flag, str): 518 | raise TypeError("day_night_flag must be of type str.") 519 | 520 | day_night_flag = day_night_flag.lower() 521 | 522 | if day_night_flag not in ['day', 'night', 'unspecified']: 523 | raise ValueError("day_night_flag must be day, night or unspecified.") 524 | 525 | self.params['day_night_flag'] = day_night_flag 526 | return self 527 | 528 | def cloud_cover(self, min_cover=0, max_cover=100): 529 | """ 530 | Filter by the percentage of cloud cover present in the granule. 531 | 532 | :param min_cover: minimum percentage of cloud cover 533 | :param max_cover: maximum percentage of cloud cover 534 | :returns: Query instance 535 | """ 536 | 537 | if not min_cover and not max_cover: 538 | raise ValueError("Please provide at least min_cover, max_cover or both") 539 | 540 | if min_cover and max_cover: 541 | try: 542 | minimum = float(min_cover) 543 | maxiumum = float(max_cover) 544 | 545 | if minimum > maxiumum: 546 | raise ValueError("Please ensure min_cloud_cover is lower than max cloud cover") 547 | except ValueError: 548 | raise ValueError("Please ensure min_cover and max_cover are both floats") 549 | 550 | self.params['cloud_cover'] = "{},{}".format(min_cover, max_cover) 551 | return self 552 | 553 | def instrument(self, instrument=""): 554 | """ 555 | Filter by the instrument associated with the granule. 556 | 557 | :param instrument: name of the instrument 558 | :returns: Query instance 559 | """ 560 | 561 | if not instrument: 562 | raise ValueError("Please provide a value for instrument") 563 | 564 | self.params['instrument'] = instrument 565 | return self 566 | 567 | def platform(self, platform=""): 568 | """ 569 | Filter by the satellite platform the granule came from. 570 | 571 | :param platform: name of the satellite 572 | :returns: Query instance 573 | """ 574 | 575 | if not platform: 576 | raise ValueError("Please provide a value for platform") 577 | 578 | self.params['platform'] = platform 579 | return self 580 | 581 | def granule_ur(self, granule_ur=""): 582 | """ 583 | Filter by the granules unique ID. Note this will result in at most one granule 584 | being returned. 585 | 586 | :param granule_ur: UUID of a granule 587 | :returns: Query instance 588 | """ 589 | 590 | if not granule_ur: 591 | raise ValueError("Please provide a value for platform") 592 | 593 | self.params['granule_ur'] = granule_ur 594 | return self 595 | 596 | def concept_id(self, IDs): 597 | """ 598 | Filter by concept ID (ex: C1299783579-LPDAAC_ECS or G1327299284-LPDAAC_ECS) 599 | 600 | Collections and granules are uniquely identified with this ID. If providing a collection's concept ID 601 | here, it will filter by granules associated with that collection. If providing a granule's concept ID 602 | here, it will uniquely identify those granules. 603 | 604 | :param IDs: concept ID(s) to search by. Can be provided as a string or list of strings. 605 | :returns: Query instance 606 | """ 607 | 608 | if isinstance(IDs, str): 609 | IDs = [IDs] 610 | 611 | self.params["concept_id"] = IDs 612 | 613 | return self 614 | 615 | def _valid_state(self): 616 | 617 | # spatial params must be paired with a collection limiting parameter 618 | spatial_keys = ["point", "polygon", "bounding_box", "line"] 619 | collection_keys = ["short_name", "entry_title"] 620 | 621 | if any(key in self.params for key in spatial_keys): 622 | if not any(key in self.params for key in collection_keys): 623 | return False 624 | 625 | # all good then 626 | return True 627 | 628 | 629 | class CollectionQuery(Query): 630 | """ 631 | Class for querying collections from the CMR. 632 | """ 633 | 634 | def __init__(self, mode=CMR_OPS): 635 | Query.__init__(self, "collections", mode) 636 | 637 | self._valid_formats_regex.extend([ 638 | "dif", "dif10", "opendata", "umm_json", "umm_json_v[0-9]_[0-9]" 639 | ]) 640 | 641 | def archive_center(self, center): 642 | """ 643 | Filter by the archive center that maintains the collection. 644 | 645 | :param archive_center: name of center as a string 646 | :returns: Query instance 647 | """ 648 | 649 | if center: 650 | self.params['archive_center'] = center 651 | 652 | return self 653 | 654 | def keyword(self, text): 655 | """ 656 | Case insentive and wildcard (*) search through over two dozen fields in 657 | a CMR collection record. This allows for searching against fields like 658 | summary and science keywords. 659 | 660 | :param text: text to search for 661 | :returns: Query instance 662 | """ 663 | 664 | if text: 665 | self.params['keyword'] = text 666 | 667 | return self 668 | 669 | def concept_id(self, IDs): 670 | """ 671 | Filter by concept ID (ex: C1299783579-LPDAAC_ECS) 672 | 673 | Collections are uniquely identified with this ID. 674 | 675 | :param IDs: concept ID(s) to search by. Can be provided as a string or list of strings. 676 | :returns: Query instance 677 | """ 678 | 679 | if isinstance(IDs, str): 680 | IDs = [IDs] 681 | 682 | # verify we weren't provided any granule concept IDs 683 | for ID in IDs: 684 | if ID.strip()[0] != "C": 685 | raise ValueError("Only collection concept ID's can be provided (begin with 'C'): {}".format(ID)) 686 | 687 | self.params["concept_id"] = IDs 688 | 689 | return self 690 | 691 | def _valid_state(self): 692 | return True 693 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2019.6.16 2 | chardet==3.0.4 3 | idna==2.8 4 | pkg-resources==0.0.0 5 | requests==2.22.0 6 | urllib3==1.25.3 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="python-cmr", 5 | version="0.4.1", 6 | license="MIT", 7 | url="https://github.com/jddeal/python-cmr", 8 | description="Python wrapper to the NASA Common Metadata Repository (CMR) API.", 9 | long_description=open("README.rst").read(), 10 | author="Justin Deal, Matt Isnor", 11 | author_email="deal.justin@gmail.com, isnor.matt@gmail.com", 12 | packages=["cmr"], 13 | install_requires=[ 14 | "requests", 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jddeal/python-cmr/5d02e4a66f30010435c953873dbf4e6528945480/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_collection.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from cmr.queries import CollectionQuery 4 | 5 | class TestCollectionClass(unittest.TestCase): 6 | 7 | def test_archive_center(self): 8 | query = CollectionQuery() 9 | query.archive_center("LP DAAC") 10 | 11 | self.assertIn("archive_center", query.params) 12 | self.assertEqual(query.params["archive_center"], "LP DAAC") 13 | 14 | def test_keyword(self): 15 | query = CollectionQuery() 16 | query.keyword("AST_*") 17 | 18 | self.assertIn("keyword", query.params) 19 | self.assertEqual(query.params["keyword"], "AST_*") 20 | 21 | def test_valid_formats(self): 22 | query = CollectionQuery() 23 | formats = [ 24 | "json", "xml", "echo10", "iso", "iso19115", 25 | "csv", "atom", "kml", "native", "dif", "dif10", 26 | "opendata", "umm_json", "umm_json_v1_1" "umm_json_v1_9"] 27 | 28 | for _format in formats: 29 | query.format(_format) 30 | self.assertEqual(query._format, _format) 31 | 32 | def test_invalid_format(self): 33 | query = CollectionQuery() 34 | 35 | with self.assertRaises(ValueError): 36 | query.format("invalid") 37 | query.format("jsonn") 38 | query.format("iso19116") 39 | 40 | def test_valid_concept_id(self): 41 | query = CollectionQuery() 42 | 43 | query.concept_id("C1299783579-LPDAAC_ECS") 44 | self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS"]) 45 | 46 | query.concept_id(["C1299783579-LPDAAC_ECS", "C1441380236-PODAAC"]) 47 | self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS", "C1441380236-PODAAC"]) 48 | 49 | def test_invalid_concept_id(self): 50 | query = CollectionQuery() 51 | 52 | with self.assertRaises(ValueError): 53 | query.concept_id("G1327299284-LPDAAC_ECS") 54 | 55 | with self.assertRaises(ValueError): 56 | query.concept_id(["C1299783579-LPDAAC_ECS", "G1327299284-LPDAAC_ECS"]) 57 | 58 | -------------------------------------------------------------------------------- /tests/test_granule.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from datetime import datetime 4 | from cmr.queries import GranuleQuery, CMR_OPS 5 | 6 | class TestGranuleClass(unittest.TestCase): 7 | 8 | short_name_val = "MOD09GA" 9 | short_name = "short_name" 10 | 11 | version_val = "006" 12 | version = "version" 13 | 14 | point = "point" 15 | online_only = "online_only" 16 | downloadable = "downloadable" 17 | entry_id = "entry_title" 18 | orbit_number = "orbit_number" 19 | day_night_flag = "day_night_flag" 20 | cloud_cover = "cloud_cover" 21 | instrument = "instrument" 22 | platform = "platform" 23 | granule_ur = "granule_ur" 24 | 25 | def test_short_name(self): 26 | query = GranuleQuery() 27 | query.short_name(self.short_name_val) 28 | 29 | self.assertIn(self.short_name, query.params) 30 | self.assertEqual(query.params[self.short_name], self.short_name_val) 31 | 32 | def test_version(self): 33 | query = GranuleQuery() 34 | query.version(self.version_val) 35 | 36 | self.assertIn(self.version, query.params) 37 | self.assertEqual(query.params[self.version], self.version_val) 38 | 39 | def test_point_set(self): 40 | query = GranuleQuery() 41 | 42 | query.point(10, 15.1) 43 | 44 | self.assertIn(self.point, query.params) 45 | self.assertEqual(query.params[self.point], "10.0,15.1") 46 | 47 | def test_point_invalid_set(self): 48 | query = GranuleQuery() 49 | 50 | with self.assertRaises(ValueError): 51 | query.point("invalid", 15.1) 52 | query.point(10, None) 53 | 54 | def test_temporal_invalid_strings(self): 55 | query = GranuleQuery() 56 | 57 | with self.assertRaises(ValueError): 58 | query.temporal("2016", "2016-10-20T01:02:03Z") 59 | query.temporal("2016-10-20T01:02:03Z", "2016") 60 | 61 | def test_temporal_invalid_types(self): 62 | query = GranuleQuery() 63 | 64 | with self.assertRaises(ValueError): 65 | query.temporal(1, 2) 66 | query.temporal(None, None) 67 | 68 | def test_temporal_invalid_date_order(self): 69 | query = GranuleQuery() 70 | 71 | with self.assertRaises(ValueError): 72 | query.temporal(datetime(2016, 10, 12, 10, 55, 7), datetime(2016, 10, 12, 9)) 73 | 74 | def test_temporal_set(self): 75 | query = GranuleQuery() 76 | 77 | # both strings 78 | query.temporal("2016-10-10T01:02:03Z", "2016-10-12T09:08:07Z") 79 | self.assertIn("temporal", query.params) 80 | self.assertEqual(query.params["temporal"][0], "2016-10-10T01:02:03Z,2016-10-12T09:08:07Z") 81 | 82 | # string and datetime 83 | query.temporal("2016-10-10T01:02:03Z", datetime(2016, 10, 12, 9)) 84 | self.assertIn("temporal", query.params) 85 | self.assertEqual(query.params["temporal"][1], "2016-10-10T01:02:03Z,2016-10-12T09:00:00Z") 86 | 87 | # string and None 88 | query.temporal(datetime(2016, 10, 12, 10, 55, 7), None) 89 | self.assertIn("temporal", query.params) 90 | self.assertEqual(query.params["temporal"][2], "2016-10-12T10:55:07Z,") 91 | 92 | # both datetimes 93 | query.temporal(datetime(2016, 10, 12, 10, 55, 7), datetime(2016, 10, 12, 11)) 94 | self.assertIn("temporal", query.params) 95 | self.assertEqual(query.params["temporal"][3], "2016-10-12T10:55:07Z,2016-10-12T11:00:00Z") 96 | 97 | def test_temporal_option_set(self): 98 | query = GranuleQuery() 99 | 100 | query.temporal("2016-10-10T01:02:03Z", "2016-10-12T09:08:07Z", exclude_boundary=True) 101 | self.assertIn("exclude_boundary", query.options["temporal"]) 102 | self.assertEqual(query.options["temporal"]["exclude_boundary"], True) 103 | 104 | def test_online_only_set(self): 105 | query = GranuleQuery() 106 | 107 | # default to True 108 | query.online_only() 109 | self.assertIn(self.online_only, query.params) 110 | self.assertEqual(query.params[self.online_only], True) 111 | 112 | # explicitly set to False 113 | query.online_only(False) 114 | 115 | self.assertIn(self.online_only, query.params) 116 | self.assertEqual(query.params[self.online_only], False) 117 | 118 | def test_online_only_invalid(self): 119 | query = GranuleQuery() 120 | 121 | with self.assertRaises(TypeError): 122 | query.online_only("Invalid Type") 123 | 124 | self.assertNotIn(self.online_only, query.params) 125 | 126 | def test_downloadable_set(self): 127 | query = GranuleQuery() 128 | 129 | # default to True 130 | query.downloadable() 131 | 132 | self.assertIn(self.downloadable, query.params) 133 | self.assertEqual(query.params[self.downloadable], True) 134 | 135 | # explicitly set to False 136 | query.downloadable(False) 137 | 138 | self.assertIn(self.downloadable, query.params) 139 | self.assertEqual(query.params[self.downloadable], False) 140 | 141 | def test_downloadable_invalid(self): 142 | query = GranuleQuery() 143 | 144 | with self.assertRaises(TypeError): 145 | query.downloadable("Invalid Type") 146 | self.assertNotIn(self.downloadable, query.params) 147 | 148 | def test_flags_invalidate_the_other(self): 149 | query = GranuleQuery() 150 | 151 | # if downloadable is set, online_only should be unset 152 | query.downloadable() 153 | self.assertIn(self.downloadable, query.params) 154 | self.assertNotIn(self.online_only, query.params) 155 | 156 | # if online_only is set, downloadable should be unset 157 | query.online_only() 158 | self.assertIn(self.online_only, query.params) 159 | self.assertNotIn(self.downloadable, query.params) 160 | 161 | def test_entry_title_set(self): 162 | query = GranuleQuery() 163 | query.entry_title("DatasetId 5") 164 | 165 | self.assertIn(self.entry_id, query.params) 166 | self.assertEqual(query.params[self.entry_id], "DatasetId%205") 167 | 168 | def test_orbit_number_set(self): 169 | query = GranuleQuery() 170 | query.orbit_number(985) 171 | 172 | self.assertIn(self.orbit_number, query.params) 173 | self.assertEqual(query.params[self.orbit_number], 985) 174 | 175 | def test_orbit_number_encode(self): 176 | query = GranuleQuery() 177 | query.orbit_number("985", "986") 178 | 179 | self.assertIn(self.orbit_number, query.params) 180 | self.assertEqual(query.params[self.orbit_number], "985%2C986") 181 | 182 | def test_day_night_flag_day_set(self): 183 | query = GranuleQuery() 184 | query.day_night_flag('day') 185 | 186 | self.assertIn(self.day_night_flag, query.params) 187 | self.assertEqual(query.params[self.day_night_flag], 'day') 188 | 189 | def test_day_night_flag_night_set(self): 190 | query = GranuleQuery() 191 | query.day_night_flag('night') 192 | 193 | self.assertIn(self.day_night_flag, query.params) 194 | self.assertEqual(query.params[self.day_night_flag], 'night') 195 | 196 | def test_day_night_flag_unspecified_set(self): 197 | query = GranuleQuery() 198 | query.day_night_flag('unspecified') 199 | 200 | self.assertIn(self.day_night_flag, query.params) 201 | self.assertEqual(query.params[self.day_night_flag], 'unspecified') 202 | 203 | def test_day_night_flag_invalid_set(self): 204 | query = GranuleQuery() 205 | 206 | with self.assertRaises(ValueError): 207 | query.day_night_flag('invaliddaynight') 208 | self.assertNotIn(self.day_night_flag, query.params) 209 | 210 | def test_day_night_flag_invalid_type_set(self): 211 | query = GranuleQuery() 212 | 213 | with self.assertRaises(TypeError): 214 | query.day_night_flag(True) 215 | self.assertNotIn(self.day_night_flag, query.params) 216 | 217 | def test_cloud_cover_min_only(self): 218 | query = GranuleQuery() 219 | query.cloud_cover(-70) 220 | 221 | self.assertIn(self.cloud_cover, query.params) 222 | self.assertEqual(query.params[self.cloud_cover], "-70,100") 223 | 224 | def test_cloud_cover_max_only(self): 225 | query = GranuleQuery() 226 | query.cloud_cover("", 120) 227 | 228 | self.assertIn(self.cloud_cover, query.params) 229 | self.assertEqual(query.params[self.cloud_cover], ",120") 230 | 231 | def test_cloud_cover_all(self): 232 | query = GranuleQuery() 233 | query.cloud_cover(-70, 120) 234 | 235 | self.assertIn(self.cloud_cover, query.params) 236 | self.assertEqual(query.params[self.cloud_cover], "-70,120") 237 | 238 | def test_cloud_cover_none(self): 239 | query = GranuleQuery() 240 | query.cloud_cover() 241 | 242 | self.assertIn(self.cloud_cover, query.params) 243 | self.assertEqual(query.params[self.cloud_cover], "0,100") 244 | 245 | def test_instrument(self): 246 | query = GranuleQuery() 247 | 248 | query.instrument("1B") 249 | 250 | self.assertIn(self.instrument, query.params) 251 | self.assertEqual(query.params[self.instrument], "1B") 252 | 253 | def test_empty_instrument(self): 254 | query = GranuleQuery() 255 | 256 | with self.assertRaises(ValueError): 257 | query.instrument(None) 258 | 259 | def test_platform(self): 260 | query = GranuleQuery() 261 | 262 | query.platform("1B") 263 | 264 | self.assertIn(self.platform, query.params) 265 | self.assertEqual(query.params[self.platform], "1B") 266 | 267 | def test_empty_platform(self): 268 | query = GranuleQuery() 269 | 270 | with self.assertRaises(ValueError): 271 | query.platform(None) 272 | 273 | def test_granule_ur(self): 274 | query = GranuleQuery() 275 | 276 | query.granule_ur("1B") 277 | 278 | self.assertIn(self.granule_ur, query.params) 279 | self.assertEqual(query.params[self.granule_ur], "1B") 280 | 281 | def test_empty_granule_ur(self): 282 | query = GranuleQuery() 283 | 284 | with self.assertRaises(ValueError): 285 | query.granule_ur(None) 286 | 287 | def test_polygon_invalid_set(self): 288 | query = GranuleQuery() 289 | 290 | with self.assertRaises(ValueError): 291 | query.polygon([1, 2, 3]) 292 | query.polygon([("invalid", 1)]) 293 | query.polygon([(1, 1), (2, 1), (1, 1)]) 294 | 295 | def test_polygon_set(self): 296 | query = GranuleQuery() 297 | 298 | query.polygon([(1, 1), (2, 1), (2, 2), (1, 1)]) 299 | self.assertEqual(query.params["polygon"], "1.0,1.0,2.0,1.0,2.0,2.0,1.0,1.0") 300 | 301 | query.polygon([("1", 1.1), (2, 1), (2, 2), (1, 1.1)]) 302 | self.assertEqual(query.params["polygon"], "1.0,1.1,2.0,1.0,2.0,2.0,1.0,1.1") 303 | 304 | def test_bounding_box_invalid_set(self): 305 | query = GranuleQuery() 306 | 307 | with self.assertRaises(ValueError): 308 | query.bounding_box(1, 2, 3, "invalid") 309 | 310 | def test_bounding_box_set(self): 311 | query = GranuleQuery() 312 | 313 | query.bounding_box(1, 2, 3, 4) 314 | self.assertEqual(query.params["bounding_box"], "1.0,2.0,3.0,4.0") 315 | 316 | def test_line_invalid_set(self): 317 | query = GranuleQuery() 318 | 319 | with self.assertRaises(ValueError): 320 | query.line("invalid") 321 | query.line([(1, 1)]) 322 | query.line(1) 323 | 324 | def test_line_set(self): 325 | query = GranuleQuery() 326 | 327 | query.line([(1, 1), (2, 2)]) 328 | self.assertEqual(query.params["line"], "1.0,1.0,2.0,2.0") 329 | 330 | query.line([("1", 1.1), (2, 2)]) 331 | self.assertEqual(query.params["line"], "1.0,1.1,2.0,2.0") 332 | 333 | def test_invalid_spatial_state(self): 334 | query = GranuleQuery() 335 | 336 | query.point(1, 2) 337 | self.assertFalse(query._valid_state()) 338 | 339 | query.polygon([(1, 1), (2, 1), (2, 2), (1, 1)]) 340 | self.assertFalse(query._valid_state()) 341 | 342 | query.bounding_box(1, 1, 2, 2) 343 | self.assertFalse(query._valid_state()) 344 | 345 | query.line([(1, 1), (2, 2)]) 346 | self.assertFalse(query._valid_state()) 347 | 348 | def test_valid_spatial_state(self): 349 | query = GranuleQuery() 350 | 351 | query.point(1, 2).short_name("test") 352 | self.assertTrue(query._valid_state()) 353 | 354 | def _test_get(self): 355 | """ Test real query """ 356 | 357 | query = GranuleQuery() 358 | query.short_name('MCD43A4').version('005') 359 | query.temporal(datetime(2016, 1, 1), datetime(2016, 1, 1)) 360 | results = query.get(limit=10) 361 | 362 | self.assertEqual(len(results), 10) 363 | 364 | def _test_hits(self): 365 | """ integration test for hits() """ 366 | 367 | query = GranuleQuery() 368 | query.short_name("AST_L1T").version("003").temporal("2016-10-26T01:30:00Z", "2016-10-26T01:40:00Z") 369 | hits = query.hits() 370 | 371 | self.assertEqual(hits, 3) 372 | 373 | def test_invalid_mode(self): 374 | query = GranuleQuery() 375 | 376 | with self.assertRaises(ValueError): 377 | query.mode(None) 378 | 379 | def test_invalid_mode_constructor(self): 380 | with self.assertRaises(ValueError): 381 | query = GranuleQuery(None) 382 | 383 | def test_valid_parameters(self): 384 | query = GranuleQuery() 385 | 386 | query.parameters(short_name="AST_L1T", version="003", point=(-100, 42)) 387 | 388 | self.assertEqual(query.params["short_name"], "AST_L1T") 389 | self.assertEqual(query.params["version"], "003") 390 | self.assertEqual(query.params["point"], "-100.0,42.0") 391 | 392 | def test_invalid_parameters(self): 393 | query = GranuleQuery() 394 | 395 | with self.assertRaises(ValueError): 396 | query.parameters(fake=123) 397 | query.parameters(point=(-100, "badvalue")) 398 | 399 | def test_valid_formats(self): 400 | query = GranuleQuery() 401 | formats = ["json", "xml", "echo10", "iso", "iso19115", "csv", "atom", "kml", "native"] 402 | 403 | for _format in formats: 404 | query.format(_format) 405 | self.assertEqual(query._format, _format) 406 | 407 | def test_invalid_format(self): 408 | query = GranuleQuery() 409 | 410 | with self.assertRaises(ValueError): 411 | query.format("invalid") 412 | query.format("jsonn") 413 | query.format("iso19116") 414 | 415 | def test_lowercase_bool_url(self): 416 | query = GranuleQuery() 417 | query.parameters(short_name="AST_LIT", online_only=True, downloadable=False) 418 | 419 | url = query._build_url() 420 | self.assertNotIn("True", url) 421 | self.assertNotIn("False", url) 422 | 423 | def test_valid_concept_id(self): 424 | query = GranuleQuery() 425 | 426 | query.concept_id("C1299783579-LPDAAC_ECS") 427 | self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS"]) 428 | 429 | query.concept_id(["C1299783579-LPDAAC_ECS", "G1441380236-PODAAC"]) 430 | self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS", "G1441380236-PODAAC"]) 431 | 432 | --------------------------------------------------------------------------------