Skip to content

Comments

Add terrascope module for Terrascope STAC API integration#1294

Merged
giswqs merged 6 commits intomasterfrom
add-terrascope-module
Feb 8, 2026
Merged

Add terrascope module for Terrascope STAC API integration#1294
giswqs merged 6 commits intomasterfrom
add-terrascope-module

Conversation

@giswqs
Copy link
Member

@giswqs giswqs commented Feb 8, 2026

Summary

Adds a new leafmap.terrascope module for seamless integration with the Terrascope STAC API.

Features

  • OAuth2 authentication with automatic token refresh (background thread)
  • Token caching to ~/.terrascope_tokens.json for persistence across sessions
  • STAC search helpers: search(), search_ndvi(), list_collections()
  • Visualization helpers: create_time_layers() for time slider
  • Utility functions: cleanup_tile_servers(), get_asset_urls(), get_item_dates()

Usage

import leafmap
import leafmap.terrascope as terrascope

# Authenticate (uses TERRASCOPE_USERNAME/PASSWORD env vars)
terrascope.login()

# Search for NDVI data
items = terrascope.search_ndvi(
    bbox=[5.0, 51.2, 5.1, 51.3],
    start="2025-05-01",
    end="2025-06-01",
    max_cloud_cover=10,
)

# Visualize
m = leafmap.Map()
m.add_raster(items[0].assets["NDVI"].href, colormap="RdYlGn")

Files Added

  • leafmap/terrascope.py - Main module
  • docs/notebooks/115_terrascope.ipynb - Example notebook
  • docs/terrascope.md - API documentation

Reference

- Add leafmap/terrascope.py with OAuth2 auth, token caching, and STAC search
- Add docs/notebooks/115_terrascope.ipynb example notebook
- Add API documentation
- Update mkdocs.yml
Copilot AI review requested due to automatic review settings February 8, 2026 06:06
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new leafmap.terrascope module to integrate with the Terrascope STAC API, including OAuth2 token management, STAC search helpers, and visualization utilities, along with documentation and an example notebook.

Changes:

  • Added leafmap.terrascope with OAuth2 login/token caching/refresh and STAC helper utilities.
  • Added API docs page and a new example notebook demonstrating authentication, search, and visualization.
  • Updated MkDocs navigation to include the new module docs and notebook.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.

File Description
mkdocs.yml Adds the Terrascope module doc page and notebook to the site navigation.
leafmap/terrascope.py Implements Terrascope auth/token handling, STAC search helpers, and visualization utilities.
docs/terrascope.md Adds mkdocstrings-based API reference page for leafmap.terrascope.
docs/notebooks/115_terrascope.ipynb Provides an end-to-end usage example (login, search, map viz, basic analysis).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +151 to +155


def _background_refresher() -> None:
"""Background thread that refreshes token periodically."""
while not _refresh_stop.wait(REFRESH_INTERVAL):
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The background refresh thread calls get_token() while other code can also call get_token()/logout(), but _token_cache and the token/header files are mutated without any synchronization. This can lead to races (e.g., logout() deleting files while the refresher rewrites them). Add a threading.Lock (or similar) around token cache + file read/write operations, and coordinate shutdown with the refresher.

Copilot uses AI. Check for mistakes.
Comment on lines 109 to 113
"""Get new tokens using password grant."""
_check_dependencies()
response = requests.post(
TOKEN_URL,
data={
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token requests use requests.post(...) without a timeout. If the network hangs, login()/get_token() can block indefinitely. Please provide a reasonable timeout (and consider surfacing a clearer error on timeout).

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +136
"""Get new access token using refresh token."""
_check_dependencies()
response = requests.post(
TOKEN_URL,
data={
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Token refresh requests use requests.post(...) without a timeout, which can block the refresh thread (and potentially get_token()) indefinitely on network hangs. Please add a timeout here as well.

Copilot uses AI. Check for mistakes.
Comment on lines 291 to 294
cached expired tokens.
"""
try:
subprocess.run(["pkill", "-f", "localtileserver"], capture_output=True)
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup_tile_servers() runs pkill -f localtileserver, which will terminate any matching process on the machine (not just ones started by this session) and is OS-specific. Consider narrowing the kill criteria (e.g., track PIDs started by leafmap/localtileserver, or use psutil to filter by current user/parent) and returning a status so callers can tell whether anything was stopped.

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 38
from pystac import ItemCollection
except ImportError:
Client = None
ItemCollection = None
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ItemCollection is imported but never used in this module, which will trigger a linter error (e.g., flake8 F401). Please remove the unused import (and the corresponding ItemCollection = None fallback) or start using it.

Suggested change
from pystac import ItemCollection
except ImportError:
Client = None
ItemCollection = None
except ImportError:
Client = None

Copilot uses AI. Check for mistakes.
Comment on lines 246 to 247
_update_header_file(token)

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

login() overwrites process-wide GDAL environment variables (GDAL_HTTP_HEADER_FILE, GDAL_DISABLE_READDIR_ON_OPEN) without preserving previous values. This can unexpectedly affect other raster/GDAL usage in the same process; consider saving the prior values and restoring them in logout() (or using a scoped context manager).

Copilot uses AI. Check for mistakes.
os.remove(TOKEN_CACHE_PATH)
if os.path.exists(HEADER_FILE_PATH):
os.remove(HEADER_FILE_PATH)

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logout() removes the header file but does not unset/restore GDAL_HTTP_HEADER_FILE (and GDAL_DISABLE_READDIR_ON_OPEN). After logout, GDAL may still try to read a non-existent header file, causing confusing failures elsewhere. Please unset or restore these environment variables when logging out.

Suggested change
# Unset GDAL environment variables configured in login()
os.environ.pop("GDAL_HTTP_HEADER_FILE", None)
os.environ.pop("GDAL_DISABLE_READDIR_ON_OPEN", None)

Copilot uses AI. Check for mistakes.
Comment on lines 155 to 159
while not _refresh_stop.wait(REFRESH_INTERVAL):
try:
token = get_token()
_update_header_file(token)
except Exception:
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The background refresher suppresses all exceptions (except Exception: pass), which makes token refresh failures very hard to diagnose and can leave the GDAL header token stale. Consider logging the exception (at least at debug/warn level) and/or exposing the last refresh error to callers.

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +170


def get_token(
username: str | None = None,
password: str | None = None,
) -> str:
"""
Get a valid Terrascope access token.

Attempts to get a token in this order:
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new module introduces non-trivial auth/token caching + refresh behavior, but there are no unit tests covering it. Consider adding tests that mock requests.post and validate: cache read/write behavior, refresh vs password fallback logic, and that logout() cleans up state (thread stop + env vars).

Copilot uses AI. Check for mistakes.
"""
try:
subprocess.run(["pkill", "-f", "localtileserver"], capture_output=True)
except Exception:
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except Exception:
except Exception:
# Ignore errors from pkill: this cleanup is best-effort and may fail
# if the command is unavailable or no matching processes are found.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

github-actions bot commented Feb 8, 2026

@github-actions github-actions bot temporarily deployed to pull request February 8, 2026 06:13 Inactive
- Add threading.Lock for token cache thread safety
- Add REQUEST_TIMEOUT (30s) to all requests.post calls
- Remove unused ItemCollection import
- Add logging for background refresh errors
- Unset GDAL env vars in logout()
- Add explanatory comments for exception handling
- Document OAuth2 password grant security in docstrings
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +181 to +235
def get_token(
username: str | None = None,
password: str | None = None,
) -> str:
"""
Get a valid Terrascope access token.

Attempts to get a token in this order:
1. Return cached token if still valid
2. Refresh using refresh token if available
3. Login with username/password

Args:
username: Terrascope username. Defaults to TERRASCOPE_USERNAME env var.
password: Terrascope password. Defaults to TERRASCOPE_PASSWORD env var.

Returns:
Valid access token string.

Raises:
ValueError: If credentials are not provided and not in environment.
requests.HTTPError: If authentication fails.
"""
global _token_cache
_check_dependencies()

with _token_lock:
if not _token_cache:
_load_cached_tokens()

now = time.time()

# Check if access token is still valid
if _token_cache.get("access_expires_at", 0) > now:
return _token_cache["access_token"]

# Try to refresh if refresh token is still valid
if _token_cache.get("refresh_expires_at", 0) > now:
try:
return _refresh_access_token(_token_cache["refresh_token"])
except Exception:
# Fall through to password auth
pass

# Need fresh login with credentials
username = username or os.environ.get("TERRASCOPE_USERNAME")
password = password or os.environ.get("TERRASCOPE_PASSWORD")
if not username or not password:
raise ValueError(
"Terrascope credentials required. Either pass username/password "
"or set TERRASCOPE_USERNAME and TERRASCOPE_PASSWORD environment "
"variables."
)
return _get_token_with_password(username, password)

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module introduces substantial new behavior (OAuth token caching/refresh, STAC search helpers). The repo has an existing test suite, but there are no tests added for these new functions. Add unit tests that cover token cache load/save behavior, refresh vs password flow selection, and search() query construction (e.g., datetime ranges and cloud-cover filtering) using mocked HTTP/STAC client responses.

Copilot uses AI. Check for mistakes.
Comment on lines +298 to +299
_refresh_thread.join(timeout=1)
_refresh_thread = None
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logout() joins the refresh thread with timeout=1 and then unconditionally sets _refresh_thread=None. If the thread is in the middle of a network refresh (or otherwise takes >1s), it may still be running while tokens/header files are being deleted, causing races and confusing state. Consider joining without a timeout, or checking is_alive() and only clearing the reference once the thread has actually stopped (and/or increasing the timeout).

Suggested change
_refresh_thread.join(timeout=1)
_refresh_thread = None
# Give the refresh thread a chance to terminate cleanly.
_refresh_thread.join(timeout=5)
if _refresh_thread.is_alive():
logging.warning(
"Terrascope token refresh thread did not stop within the logout timeout."
)
else:
_refresh_thread = None

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +174
with _token_lock:
token = get_token()
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_background_refresher() acquires _token_lock and then calls get_token(), which also acquires _token_lock. This will deadlock the background refresh thread the first time it runs. Remove the outer lock and rely on get_token()'s internal locking, or refactor to fetch the token outside the lock and only lock around updating shared state/files.

Suggested change
with _token_lock:
token = get_token()
token = get_token()
with _token_lock:

Copilot uses AI. Check for mistakes.
Comment on lines +545 to +561
try:
import leafmap
except ImportError:
raise ImportError("leafmap is required: pip install leafmap")

layers = {}
for item in items:
if asset_key not in item.assets:
continue
date_str = item.datetime.strftime("%Y-%m-%d")
tile_layer = leafmap.get_local_tile_layer(
item.assets[asset_key].href,
layer_name=date_str,
colormap=colormap,
vmin=vmin,
vmax=vmax,
)
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_time_layers() calls leafmap.get_local_tile_layer(), but leafmap.init only re-exports get_local_tile_layer in some backends (e.g., ipyleaflet via leafmap/leafmap.py). In folium/mkdocs mode it isn’t exported, so this will raise AttributeError. Import and call get_local_tile_layer from leafmap.common (or use a relative import from .common) to make this backend-independent.

Copilot uses AI. Check for mistakes.
@github-actions github-actions bot temporarily deployed to pull request February 8, 2026 06:23 Inactive
@giswqs giswqs merged commit 3e28dcd into master Feb 8, 2026
14 checks passed
@giswqs giswqs deleted the add-terrascope-module branch February 8, 2026 06:26
@github-actions github-actions bot temporarily deployed to pull request February 8, 2026 06:31 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant