Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
094651f
bump develop version
frankinspace Sep 13, 2024
b5fc6d9
Merge remote-tracking branch 'refs/remotes/origin/main' into develop
frankinspace Sep 13, 2024
69946fc
update changelog
frankinspace Sep 13, 2024
e4590ef
Added function for returning an iterator instead of a sequence. Updat…
frankinspace Sep 24, 2024
1dfc115
Added function for returning an iterator instead of a sequence. Updat…
frankinspace Sep 24, 2024
8ce8e64
Added function for returning an iterator instead of a sequence. Updat…
frankinspace Sep 24, 2024
bda1cff
Merge pull request #89
frankinspace Sep 24, 2024
9936bfe
Revert "Issues/37 Add function for returning an iterator instead of s…
frankinspace Sep 24, 2024
ac1aab3
Issues/37 Add function for returning an iterator instead of sequence …
frankinspace Sep 25, 2024
35b8bda
Bump abatilo/actions-poetry from 3 to 4 (#96)
dependabot[bot] Jan 13, 2025
c3ce7a7
Bump requests from 2.32.3 to 2.32.4 (#98)
dependabot[bot] Apr 16, 2026
fca1f23
Bump actions/setup-python from 5 to 6 (#100)
dependabot[bot] Apr 16, 2026
3a8d1f2
Bump github/codeql-action from 3 to 4 (#101)
dependabot[bot] Apr 16, 2026
4ca34f8
Bump actions/checkout from 4 to 6 (#102)
dependabot[bot] Apr 16, 2026
71966e6
Bump actions/cache from 4 to 5 (#103)
dependabot[bot] Apr 16, 2026
59f86f3
Bump idna from 3.8 to 3.15 (#109)
dependabot[bot] Jun 3, 2026
ed858fd
Support "unchecked" query parameters. (#107)
chuckwondo Jun 16, 2026
a4921f1
Support searching by multiple platforms (#119)
suhaslord Aug 23, 2026
87dfd09
Bump actions/checkout from 6 to 7 (#115)
dependabot[bot] Aug 23, 2026
6ddaed8
Bump actions/cache from 5 to 6 (#116)
dependabot[bot] Aug 23, 2026
9f7648c
Bump actions/setup-python from 6 to 7 (#117)
dependabot[bot] Aug 23, 2026
d5d174a
feat(queries): warn on likely-flipped/out-of-range bounding-box coord…
philphauler Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
Expand All @@ -54,7 +54,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4

# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
Expand All @@ -68,4 +68,4 @@ jobs:
# make release

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
8 changes: 4 additions & 4 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,20 @@ jobs:
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v3
uses: abatilo/actions-poetry@v4
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Setup a local virtual environment
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
- uses: actions/cache@v6
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ jobs:
id-token: write

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Install Poetry
uses: abatilo/actions-poetry@v3
uses: abatilo/actions-poetry@v4
with:
poetry-version: 1.5.1
- name: Build package
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Add method `Query.results` for returning results as an iterator instead of sequence ([#37](https://github.com/nasa/python_cmr/issues/37))
- Support searching by multiple platforms for collections and granules ([#80](https://github.com/nasa/python_cmr/issues/80))

### Changed

- Deprecate methods `Query.get` and `Query.get_all` in favor of the new
`Query.results` method. These deprecated methods will likely be removed for
the 1.0.0 release. ([#37](https://github.com/nasa/python_cmr/issues/37))
- `Query.parameters` accepts "unchecked" keywords, meaning that it accepts
keywords that do not have a corresponding method by the same name in the
`Query` class (or specific subclass being used).

This allows the caller to supply a parameter that does not have a
corresponding method without raising a `ValueError`. Instead, such a parameter
is passed directly through to the CMR, where it will be checked. If the
parameter is not supported or its value is invalid, the CMR response will
indicate as such.

This avoids the need to wait for the corresponding method to be added, or
having to write cumbersome code to get around the limitation.
([#106](https://github.com/nasa/python_cmr/issues/106))

## [0.13.0]

### Added
Expand Down
127 changes: 114 additions & 13 deletions cmr/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
Contains all CMR query types.
"""

import warnings

from abc import abstractmethod
from collections import defaultdict
from datetime import date, datetime, timezone
from inspect import getmembers, ismethod
from re import search
from typing import Iterable, Iterator

from typing_extensions import (
Any,
List,
Expand All @@ -20,7 +24,7 @@
Tuple,
TypeAlias,
Union,
override,
override, deprecated,
)
from urllib.parse import quote

Expand Down Expand Up @@ -58,6 +62,7 @@ def __init__(self, route: str, mode: str = CMR_OPS):
self.concept_id_chars: Set[str] = set()
self.headers: MutableMapping[str, str] = {}

@deprecated("Use the 'results' method instead, but note that it produces an iterator.")
def get(self, limit: int = 2000) -> Sequence[Any]:
"""
Get all results up to some limit, even if spanning multiple pages.
Expand Down Expand Up @@ -115,6 +120,7 @@ def hits(self) -> int:

return int(response.headers["CMR-Hits"])

@deprecated("Use the 'results' method instead, but note that it produces an iterator.")
def get_all(self) -> Sequence[Any]:
"""
Returns all of the results for the query. This will call hits() first to determine how many
Expand All @@ -124,7 +130,60 @@ def get_all(self) -> Sequence[Any]:
:returns: query results as a list
"""

return self.get(self.hits())
return list(self.get(self.hits()))

def results(self, page_size: int = 2000) -> Iterator[Any]:
"""
Return an iterator (generator) of all results matching the query
criteria.

Because a query may produce a large number of results (perhaps
10s or 100s of thousands), such results are fetched using
multiple CMR requests, each returning a "page" of results, as
returning all results in a single request would be impractical.
The size of each page (in terms of the number of results
in a page) is controlled by the `page_size` parameter. A smaller
page size means fewer items in memory (per page), requiring
more CMR queries to fetch all results (if desired). Conversely,
a larger page size means more items in memory (per page)
and fewer CMR queries.

When the query is configured to use the `"json"` format, each
element produced by the returned iterator is a element of the
"feed.entry" array (see
<https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#json>).
In this case, the iterator may produce as many elements as there
are results matching the query criteria.

For all other formats, each element produced by the returned
iterator is an unparsed (text) page of results (i.e., the caller
is responsible for parsing the page of results into individual
elements). In this case, the iterator will produce only as many
pages as required (based on `page_size`) to produce all results
matching the query criteria.

:param page_size: maximum number of results per page (min 1,
max 2000 [default]) requested from the CMR
:returns: query results as an iterator (generator)
"""

url = self._build_url()
headers = dict(self.headers or {})
params = {"page_size": min(max(1, page_size), 2000)}

while True:
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()

if self._format == "json":
yield from response.json()["feed"]["entry"]
else:
yield response.text

if not (cmr_search_after := response.headers.get("cmr-search-after")):
break

headers["cmr-search-after"] = cmr_search_after

def parameters(self, **kwargs: Any) -> Self:
"""
Expand All @@ -139,12 +198,19 @@ def parameters(self, **kwargs: Any) -> Self:
methods = dict(getmembers(self, predicate=ismethod))

for key, val in kwargs.items():
# verify the key matches one of our methods
# If the key does not match one of the methods defined in the Query
# class or subclass, simply set the parameter "unchecked" (i.e.,
# set the parameter, but without a method that can do some value
# checking. If the value is invalid, the CMR response will indicate
# the problem).
if key not in methods:
raise ValueError(f"Unknown key {key}")

# call the method
if isinstance(val, tuple):
if isinstance(val, str) or not isinstance(val, Iterable):
# Set single-valued parameter
self.params[key] = val
else:
# Set multi-valued parameter adding `[]` suffix to key
self.params[f"{key}[]"] = tuple(val)
elif isinstance(val, tuple):
methods[key](*val)
else:
methods[key](val)
Expand Down Expand Up @@ -639,9 +705,41 @@ def bounding_box(
:returns: self
"""

self.params["bounding_box"] = (
f"{float(lower_left_lon)},{float(lower_left_lat)},{float(upper_right_lon)},{float(upper_right_lat)}"
)
ll_lon = float(lower_left_lon)
ll_lat = float(lower_left_lat)
ur_lon = float(upper_right_lon)
ur_lat = float(upper_right_lat)

# Valid-domain guard. NOESIS-derived invariant (WGS84): longitude must
# lie in [-180, 180] and latitude in [-90, 90]. A value outside its own
# axis' domain almost always means the coordinates were swapped — exactly
# the silent failure earthaccess users hit (nsidc/earthaccess#746), where
# a flipped box wrapped ~179 degrees the wrong way around the Earth
# instead of erroring. We warn (matching asf_search's behaviour) rather
# than raising, so existing callers keep working.
if not (-180.0 <= ll_lon <= 180.0 and -180.0 <= ur_lon <= 180.0):
warnings.warn(
f"Longitude outside valid range [-180, 180]; coordinates may be "
f"swapped or malformed: ll_lon={ll_lon}, ur_lon={ur_lon}.",
UserWarning,
stacklevel=2,
)
if not (-90.0 <= ll_lat <= 90.0 and -90.0 <= ur_lat <= 90.0):
warnings.warn(
f"Latitude outside valid range [-90, 90]; coordinates may be "
f"swapped or malformed: ll_lat={ll_lat}, ur_lat={ur_lat}.",
UserWarning,
stacklevel=2,
)
if ll_lon > ur_lon or ll_lat > ur_lat:
warnings.warn(
"Bounding box appears inverted (lower-left is east/north of "
"upper-right); coordinates may be flipped.",
UserWarning,
stacklevel=2,
)

self.params["bounding_box"] = f"{ll_lon},{ll_lat},{ur_lon},{ur_lat}"

return self

Expand Down Expand Up @@ -717,18 +815,21 @@ def entry_title(self, entry_title: str) -> Self:

return self

def platform(self, platform: str) -> Self:
def platform(self, platform: Union[str, Sequence[str]]) -> Self:
"""
Filter by the satellite platform the granule came from.

:param platform: name of the satellite
:param platform: name of the satellite (single string) or sequence of satellite names
:returns: self
"""

if not platform:
raise ValueError("Please provide a value for platform")

self.params['platform'] = platform
self.params['platform'] = (
platform if isinstance(platform, str) else list(platform)
)

return self


Expand Down
Loading