Bhoonidhi SDK — query¶
Search the portal and manage saved queries from Python. Every method
matches a bhd query command.
Runs live. create, refresh, and download reach the portal and need
a login; list, show, fork, rename, rm work on the local query
store. Enter your real credentials when prompted.
1. Log in¶
import getpass
from bhoonidhi_downloader.sdk import BhoonidhiClient, BhoonidhiError
def get_otp(message: str) -> str:
print(message)
return input("Enter the 6-digit OTP: ")
client = BhoonidhiClient()
# otp_prompt is only called if the account needs a mailed OTP; a
# password-only account logs in without it being invoked.
client.login(
input("Bhoonidhi username: "),
getpass.getpass("Bhoonidhi password: "),
otp_prompt=get_otp,
)
print("authenticated:", client.is_authenticated)
2. Create a query¶
client.query.create(...) matches bhd query create. Date range, satellite/sensor, and the area of interest as a bounding box (minx/maxx/miny/maxy) or a point plus radius (lat/lon/radius_km) — give one or the other, not both. Returns the saved query, or None if nothing matched. Pass save=False for a stateless search: the scenes are still returned on the QuerySchema, but nothing is written to disk and no slug is generated.
query = client.query.create(
datetime(2025, 12, 1),
datetime(2025, 12, 30),
satellite="Sentinel-2A",
sensor="MSI",
minx=91.77,
maxx=92.0,
miny=25.496,
maxy=25.695,
)
if query is None:
print("no scenes matched")
else:
print("slug: ", query.slug)
print("name: ", query.name)
print("scenes:", len(query.scenes))
Output()
slug: amber-peak name: Sentinel-2A MSI scenes, Dec 2025 scenes: 3
Or search around a point¶
Same call, but lat/lon/radius_km instead of a bounding box (radius defaults to 10km, and must be between 1 and 100).
Multiple missions or a single product¶
Pass selections instead of satellite/sensor to search several missions in one call, or narrow to a single product within a sensor. Each entry is a Selection(satellite, sensor=None, product=None) — matches the CLI's --sat SAT[:SEN[:PROD]]. selections and satellite/sensor are mutually exclusive; give one or the other.
from bhoonidhi_downloader.sdk import Selection
multi_query = client.query.create(
datetime(2026, 1, 1),
datetime(2026, 1, 31),
selections=[
Selection(satellite="EOS-06", sensor="OCM(GAC)", product="L2C-NDVI"),
Selection(satellite="EOS-06", sensor="OCM(GAC)", product="L2C-Chlorophyll"),
],
minx=74,
maxx=80,
miny=12,
maxy=18,
)
print("slug: ", multi_query.slug)
print("name: ", multi_query.name)
print("scenes:", len(multi_query.scenes))
slug: deep-brook name: EOS-06/OCM(GAC)/L2C-NDVI + EOS-06/OCM(GAC)/L2C-Chlorophyll scenes, Jan 2026 scenes: 120
location_query = client.query.create(
datetime(2025, 12, 1),
datetime(2025, 12, 30),
satellite="Sentinel-2A",
sensor="MSI",
lat=25.58,
lon=91.89,
radius_km=15,
)
if location_query is None:
print("no scenes matched")
else:
print("slug: ", location_query.slug)
print("scenes:", len(location_query.scenes))
3. List saved queries¶
client.query.list() matches bhd query list.
for q in client.query.list()[:10]:
missions = " + ".join(s.label() for s in q.selections)
print(f"{q.slug:16s} {len(q.scenes):>4} scenes {missions}")
amber-glacier 897 scenes Sentinel-2A/MSI amber-isle 28 scenes ResourceSat-2A/AWIFS amber-peak 50 scenes Sentinel-2A/MSI amber-spire 164 scenes EOS-04 azure-grove 3 scenes Sentinel-2A/MSI bold-ridge 8 scenes ResourceSat-2A/LISS3 brisk-falcon 500 scenes EOS-04/SAR(MRS) brisk-hollow 99 scenes JPSS1/VIIRS brisk-sparrow 1233 scenes CartoSat-3/MX(SPOT) brisk-thicket 398 scenes ResourceSat-2A/LISS3
4. Show one query's scenes¶
client.query.show(slug) matches bhd query show. Returns the query; each scene is a dict of portal fields.
slug = query.slug if query else client.query.list()[0].slug
q = client.query.show(slug)
print("showing:", q.slug, "|", len(q.scenes), "scenes")
for s in q.scenes[:5]:
print(" ", s.get("ID"), "|", s.get("DOP"))
showing: amber-peak | 3 scenes SEN2A_MSI_zzz_18DEC2025_133_T46RDP_ESA_STUBBAOJD_20251218T065911 | 18-Dec-2025 SEN2A_MSI_zzz_18DEC2025_133_T46RDP_ESA_STUBTAOJD_20251218T052031 | 18-Dec-2025 SEN2A_MSI_zzz_18DEC2025_133_T46RCP_ESA_STUBBAOJD_20251218T065911 | 18-Dec-2025
5. Rename¶
client.query.rename(...) matches bhd query rename.
renamed = client.query.rename(slug, description="Edited from the SDK notebook")
print("name:", renamed.name)
print("desc:", renamed.description)
name: Sentinel-2A MSI scenes, Dec 2025 desc: Edited from the SDK notebook
6. Fork¶
client.query.fork(slug) matches bhd query fork — clones scenes under a new slug, no re-query.
fork = client.query.fork(slug, name="Notebook fork")
print("forked ->", fork.slug, "|", len(fork.scenes), "scenes")
forked -> steel-falcon | 3 scenes
7. Refresh¶
client.query.refresh(slug) matches bhd query refresh. Returns (query, added_count); added_count is None if already up to date.
refreshed, added = client.query.refresh(slug)
print("added:", added, "| total now:", len(refreshed.scenes))
Output()
added: 47 | total now: 50
8. Download (dry run first)¶
There is no --dry-run flag on the SDK method — instead preview with the download preview helper, then call download for real.
from pathlib import Path
from bhoonidhi_downloader.core.download import build_preview
from bhoonidhi_downloader.core.query.command import resolve_scene_selection
scenes = resolve_scene_selection(client.query.show(slug).scenes, None)
previews = build_preview(scenes, Path("/tmp/bhd_downloads"))
from collections import Counter
print(Counter(p.status for p in previews))
Counter({'would_download': 47, 'may_404': 3})
9. Download for real¶
client.query.download(slug, out, on_progress=...) matches bhd query download. Priced/on-order scenes are skipped. Uncomment to run — it fetches real files.
def show_progress(scene_id, done, total):
pct = f"{done/total*100:4.0f}%" if total else " ? "
print(f"{pct} {scene_id}", end="\r")
# select is a list of scene indices (1-based) and/or scene IDs:
# select=[1, 2, 3] -> first three scenes
# select=["RAW12JUL..."] -> a specific scene by ID
# omit select to download the whole query.
outcomes = client.query.download(slug, "/tmp/bhd_downloads", on_progress=show_progress, select=[1,4])
from collections import Counter
print(Counter(o.status for o in outcomes))
2% SEN2A_MSI_zzz_18DEC2025_133_T46RDP_ESA_STUBBAOJD_20251218T065911
10. Clean up¶
client.query.rm(slug) matches bhd query rm.
client.query.rm(fork.slug)
print("removed fork:", fork.slug)
removed fork: amber-forest