Skip to content

feat: added functions for NASA Earthdata AWS S3 endpoints#222

Merged
tsutterley merged 16 commits into
mainfrom
dev
May 29, 2026
Merged

feat: added functions for NASA Earthdata AWS S3 endpoints#222
tsutterley merged 16 commits into
mainfrom
dev

Conversation

@tsutterley

Copy link
Copy Markdown
Member

No description provided.

Copilot AI review requested due to automatic review settings April 24, 2026 15:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds new utility helpers to support NASA Earthdata Cumulus AWS S3 access and JSON HTTP responses within timescale’s existing networking/IO utilities.

Changes:

  • Added optional-dependency helpers (import_dependency, dependency_available) and a URL validator.
  • Added from_json for loading JSON responses from HTTP(S) endpoints.
  • Added initial helpers for NASA Earthdata Cumulus S3 access (s3_client, s3_region, s3_bucket, s3_key) and updated related provider/bucket mappings.

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

Comment thread timescale/utilities.py
Comment thread timescale/utilities.py
Comment thread timescale/utilities.py
Comment thread timescale/utilities.py
Comment thread timescale/utilities.py Outdated
Comment thread timescale/utilities.py
Comment thread timescale/utilities.py
Comment thread timescale/utilities.py
Comment thread timescale/utilities.py
@github-actions

github-actions Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Coverage

Tests Skipped Failures Errors Time
39 0 💤 0 ❌ 0 🔥 16m 30s ⏱️

@github-actions

github-actions Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Coverage

Tests Skipped Failures Errors Time
39 0 💤 0 ❌ 0 🔥 13m 26s ⏱️

@github-actions

github-actions Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Coverage

Tests Skipped Failures Errors Time
39 0 💤 0 ❌ 0 🔥 11m 18s ⏱️

tsutterley and others added 5 commits April 24, 2026 17:13
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
chore: bump `pixi.lock` to version 7
chore: bump ci tools to newer versions
feat: add check to see if delta time file needs updating
feat: add arguments to allow anonymous ftp use

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 12 out of 15 changed files in this pull request and generated 10 comments.

Comments suppressed due to low confidence (1)

timescale/utilities.py:1244

  • In attempt_login, prompt is computed once from the initial username, but inside the retry loop the code re-prompts for a new username and then calls getpass.getpass(prompt=prompt) without recomputing it. This can display the wrong username in the password prompt on retries; recompute prompt after updating username inside the loop.
    prompt = f"Password for {username}@{urs}: "
    if not password:
        password = getpass.getpass(prompt=prompt)
    # host for endpoint
    HOST = kwargs.get("endpoint")
    # for each retry
    for retry in range(kwargs["retries"]):
        # build an opener for urs with credentials
        opener = build_opener(
            username,
            password,
            context=context,
            password_manager=password_manager,
            get_ca_certs=get_ca_certs,
            redirect=redirect,
            authorization_header=authorization_header,
            urs=urs,
        )
        # try logging in by check credentials
        try:
            check_credentials(HOST)
        except Exception as exc:
            pass
        else:
            return opener
        # reattempt login
        username = builtins.input(f"Username for {urs}: ")
        password = getpass.getpass(prompt=prompt)
    # reached end of available retries

Comment thread timescale/utilities.py
Comment on lines +215 to +222
# check if module is available
if importlib.util.find_spec(name) is None:
return False
# check if the version is greater than the minimum required
if minversion is not None:
version = importlib.metadata.version(name)
return version >= minversion
# return if both checks are passed
Comment thread timescale/utilities.py
local: str | pathlib.Path | None = None,
hash: str = "",
chunk: int = 16384,
headers: dict = {},
Comment thread timescale/utilities.py
Comment on lines +1061 to +1066
def from_json(
HOST: str | list,
timeout: int | None = None,
context: ssl.SSLContext = _default_ssl_context,
headers: dict = {},
) -> dict:
Comment thread timescale/utilities.py Outdated
Comment on lines +112 to +117
Get a URL for the raw content of an item in the project repository

Parameters
----------
relpath: list or str
Relative path
Comment thread timescale/utilities.py
Comment on lines +1508 to +1512
AWS region name
"""
boto3 = import_dependency("boto3")
region_name = boto3.session.Session().region_name
return region_name
Comment thread timescale/utilities.py
Comment on lines +1554 to +1571
# PURPOSE: get a s3 bucket name from a presigned url
def s3_bucket(presigned_url: str) -> str:
"""
Get a s3 bucket name from a presigned url

Parameters
----------
presigned_url: str
s3 presigned url

Returns
-------
bucket: str
s3 bucket name
"""
host = url_split(presigned_url)
bucket = re.sub(r"s3:\/\/", r"", host[0], re.IGNORECASE)
return bucket
Comment thread timescale/eop.py
Comment thread timescale/eop.py Outdated
Comment thread doc/source/conf.py
Comment thread .github/workflows/sphinx-build.yml Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 13 out of 15 changed files in this pull request and generated 9 comments.

Comment thread timescale/utilities.py
"""
# check if the module name is a string
msg = f"Invalid module name: '{name}'; must be a string"
assert isinstance(name, str), msg
Comment thread timescale/utilities.py
Comment on lines +231 to +237
# check if module is available
if importlib.util.find_spec(name) is None:
return False
# check if the version is greater than the minimum required
if minversion is not None:
version = importlib.metadata.version(name)
return version >= minversion
Comment thread timescale/utilities.py
local: str | pathlib.Path | None = None,
hash: str = "",
chunk: int = 16384,
headers: dict = {},
Comment thread timescale/utilities.py
Comment on lines 1007 to +1054
@@ -833,15 +1036,22 @@ def from_http(
# Create and submit request.
request = urllib2.Request(posixpath.join(*HOST), **kwargs)
response = urllib2.urlopen(request, timeout=timeout, context=context)
except:
raise Exception("Download error from {0}".format(posixpath.join(*HOST)))
except urllib2.HTTPError as exc:
logging.debug(exc.code)
raise
except urllib2.URLError as exc:
logging.debug(exc.reason)
exc.message = "Check internet connection"
raise
else:
# copy remote file contents to bytesIO object
remote_buffer = io.BytesIO()
shutil.copyfileobj(response, remote_buffer, chunk)
remote_buffer.seek(0)
# save file basename with bytesIO object
remote_buffer.filename = HOST[-1]
# copy headers from response
headers.update({k.lower(): v for k, v in response.getheaders()})
Comment thread timescale/utilities.py
Comment on lines +1077 to +1082
def from_json(
HOST: str | list,
timeout: int | None = None,
context: ssl.SSLContext = _default_ssl_context,
headers: dict = {},
) -> dict:
Comment thread timescale/utilities.py
Comment on lines +1524 to +1528
AWS region name
"""
boto3 = import_dependency("boto3")
region_name = boto3.session.Session().region_name
return region_name
Comment thread timescale/utilities.py
Comment on lines +1554 to +1565
request = urllib2.Request(HOST)
response = urllib2.urlopen(request, timeout=timeout)
cumulus = json.loads(response.read())
# get AWS client object
boto3 = import_dependency("boto3")
client = boto3.client(
"s3",
aws_access_key_id=cumulus["accessKeyId"],
aws_secret_access_key=cumulus["secretAccessKey"],
aws_session_token=cumulus["sessionToken"],
region_name=region_name,
)
Comment thread timescale/time.py
Comment on lines +1604 to +1641
def update_delta_time(
delta_file: str | pathlib.Path,
branch: str = "main",
verbose: bool = False,
mode: oct = 0o775,
):
"""
Downloads an updated delta time file from the project GitHub repository

Parameters
----------
delta_file: str or Pathlib.Path
file containing the delta times (TT-UT1)
branch: str, default 'main'
branch of the GitHub repository to download from
verbose: bool, default False
print file information about output file
mode: oct, default 0o775
permissions mode of output file

Notes
-----
Delta times are the difference between dynamical time and universal time
"""
# verify delta time file path
delta_file = pathlib.Path(delta_file).expanduser().absolute()
# MD5 hash for comparing with remote
HASH = timescale.utilities.get_hash(delta_file)
# try downloading from GitHub repository
HOST = timescale.utilities.get_github_url(
["timescale", "data", delta_file.name], branch=branch
)
# log message for failed download
msg = f"Unable to download {delta_file.name} from timescale repository"
try:
timescale.utilities.from_http(
HOST, local=delta_file, hash=HASH, verbose=verbose, mode=mode
)
Comment thread timescale/utilities.py
Comment on lines +110 to +148
def get_github_url(
relpath: list | str,
username: str = "pyTMD",
repository: str = "timescale",
branch: str = "main",
):
"""
Get the URL of an item's raw content from a GitHub repository

Parameters
----------
relpath: list or str
Relative path
username: str, default 'pyTMD'
GitHub username for project repository
repository: str, default 'timescale'
GitHub project name
branch: str, default 'main'
GitHub branch name

Returns
-------
raw_url: str
item URL
"""
# components of the URL for raw content from the project repository
HOST = [
"https://raw.githubusercontent.com",
username,
repository,
"refs",
"heads",
branch,
]
# check if relative path is a string and convert to list
if isinstance(relpath, str):
relpath = [relpath]
# append the relative path components to the URL
return "/".join(HOST + relpath)
@tsutterley
tsutterley merged commit 1ff6b0c into main May 29, 2026
5 checks passed
@tsutterley
tsutterley deleted the dev branch May 29, 2026 21:18
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.

2 participants