Example Python Script

An example Python script for reference only.
"""
Example ODS API Client

Usage, to get ODS Data:
python example_ods_api_client.py

To additionally upload a TLE file:
python example_ods_api_client.py --tle_file_path <path to TLE file>
"""

# --------------------------------------------------------------------------------------------------
# NOTE: For demo purposes, loads client credentials from an .env file and persists an
# access token back to the .env file to be used for subsequent requests.
#
# For production, use a secure credential store like your OS keychain or secrets manager instead.
#
# Use the ODS API responsibly. Avoid unnecessary or repeated requests, rotate tokens when needed
# and respect rate limits.
# --------------------------------------------------------------------------------------------------
import argparse
import os
import time
from io import BufferedReader
from typing import Callable, Dict, Optional

import requests
from dotenv import load_dotenv, set_key
from loguru import logger

API_BASE = "https://ods.nrao.edu"
TOKEN_URL = f"{API_BASE}/token"
ODS_DATA_URL = f"{API_BASE}/ods_data"
TLE_DATA_URL = f"{API_BASE}/tle_data"

MAX_REQUEST_RETRIES = 3
RETRY_STATUS_CODES = [403, 408, 429, 500, 502, 503, 504]

# Load credentials from env file
ENV_FILE = ".ods.env"
load_dotenv(dotenv_path=ENV_FILE, verbose=True)


class ODSAPIClient:
    """Example Python ODS API Client for calling the NRAO ODS API"""

    def __init__(self, client_id: str, client_secret: str):
        self.client_id = client_id
        self.client_secret = client_secret
        self.token = os.environ.get("ODS_ACCESS_TOKEN")

    def refresh_token(self) -> None:
        """Fetch a new access token from the API and persist to be used for
        all subsequent requests. Only re-generate tokens as necessary, not per-request.

        In production, use a secure storage mechanism for client credentials.
        """
        resp: requests.Response = requests.post(
            TOKEN_URL,
            data={
                "grant_type": "client_credentials",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
            },
            timeout=5,
        )

        if resp.status_code == 200:
            new_token = resp.json()["token"]
            set_key(ENV_FILE, "ODS_ACCESS_TOKEN", new_token)
            self.token = new_token
        else:
            logger.info("Failed to refresh token")

        resp.raise_for_status()

    def ods_request_with_retry(
        self,
        method: Callable,
        url: str,
        files: Optional[Dict[str, BufferedReader]] = None,
    ):
        """
        Make requests to the ODS API with exponential backoff

        Reuse the existing token and rotate once the ODS API indicates it is no longer valid.
        """

        retries: int = 0
        response: Optional[requests.Response] = None

        while retries < MAX_REQUEST_RETRIES:
            if not self.token:
                self.refresh_token()

            response = method(
                url,
                headers={"Authorization": f"Bearer {self.token}"},
                files=files,
                timeout=5,
            )
            if response.status_code in RETRY_STATUS_CODES:
                if response.status_code == 403:
                    # Reactively refresh token if we get a 403
                    logger.info("Refreshing token")
                    self.refresh_token()
                delay: float = backoff_delay(2, retries)
                logger.info("Retrying in {} seconds...", delay)
                time.sleep(delay)
                retries += 1
            else:
                response.raise_for_status()
                return response

        if response is not None:
            response.raise_for_status()
        return response

    def get_ods_data(self) -> None:
        """Example request for getting ODS Data

        Client with appropriate scope required.
        """
        api_response = self.ods_request_with_retry(requests.get, ODS_DATA_URL)
        logger.info("Retrieved ODS data: ")
        logger.info(api_response.json()["ods_data"])

    def post_tle_file(self, filepath) -> None:
        """Example request for POSTing TLE files to ODS

        Client with appropriate scope required.

        Note, this uploads a TLE file to a live instance of NRAO ODS.
        """
        with open(filepath, "rb") as f:
            files = {"tle_file": f}
            api_response = self.ods_request_with_retry(
                requests.post, TLE_DATA_URL, files
            )
            logger.info("Uploaded TLE file to ODS: {}", api_response.json())


def backoff_delay(backoff_factor: int, attempts: int) -> float:
    """Add exponential backoff delay"""
    return backoff_factor * (2 ** (attempts - 1))


def main(tle_file_path: Optional[str] = None) -> None:
    """
    Make requests to the ODS API to get ODS Data, and upload a TLE file if applicable
    """
    # Contact an NRAO ODS Admin to generate client credentials
    # with applicable permissions. Store securely.
    ods_client_id = os.getenv("ODS_CLIENT_ID")
    ods_client_secret = os.getenv("ODS_CLIENT_SECRET")

    api_client = ODSAPIClient(ods_client_id, ods_client_secret)

    logger.info("Making example request to get ODS Data")
    api_client.get_ods_data()

    if tle_file_path:
        time.sleep(2)
        logger.info("Making request to POST a TLE File")
        api_client.post_tle_file(tle_file_path)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="A simple Python script to demonstrate talking to the ODS API"
    )

    parser.add_argument(
        "--tle_file_path",
        type=str,
        help="Optional path to a TLE file to be uploaded to ODS",
    )
    args = parser.parse_args()
    main(args.tle_file_path)