Skip to content

Commit 889f5d1

Browse files
committed
Add auth module, handle .netrc authentication
1 parent d1de921 commit 889f5d1

17 files changed

+679
-317
lines changed

ci/py310-env.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ dependencies:
77
# Core scientific python
88
- matplotlib
99
- scipy
10+
# System management
11+
- keyring
12+
- platformdirs
1013

1114
# Geo stuff
1215
- geopandas

ci/py311.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: earthpy-dev
2+
channels:
3+
- conda-forge
4+
dependencies:
5+
6+
- python=3.11
7+
# Core scientific python
8+
- matplotlib
9+
- scipy
10+
# System management
11+
- keyring
12+
- platformdirs
13+
14+
# Geo stuff
15+
- geopandas
16+
- rasterio
17+
- folium
18+
- pytest-vcr
19+
- pytest-cov
20+
- pytest
21+
- setuptools
22+
- descartes
23+
- seaborn
24+
- pillow
25+
- scikit-image
26+
- requests

ci/py312.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: earthpy-dev
2+
channels:
3+
- conda-forge
4+
dependencies:
5+
6+
- python=3.12
7+
# Core scientific python
8+
- matplotlib
9+
- scipy
10+
# System management
11+
- keyring
12+
- platformdirs
13+
14+
# Geo stuff
15+
- geopandas
16+
- rasterio
17+
- folium
18+
- pytest-vcr
19+
- pytest-cov
20+
- pytest
21+
- setuptools
22+
- descartes
23+
- seaborn
24+
- pillow
25+
- scikit-image
26+
- requests

ci/py38-env.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ dependencies:
77
# Core scientific python
88
- matplotlib
99
- scipy
10+
# System management
11+
- keyring
12+
- platformdirs
1013

1114
# Geo stuff
1215
- geopandas

ci/py39-env.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ dependencies:
77
# Core scientific python
88
- matplotlib
99
- scipy
10+
# System management
11+
- keyring
12+
- platformdirs
1013

1114
# Geo stuff
1215
- geopandas

earthpy/api/api.py

Lines changed: 9 additions & 249 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import requests
2121
import sqlite3
2222

23-
from ..io import HOME, DATA_NAME
23+
from ..project import Project
2424

2525
class APIDownloader(object):
2626
"""
@@ -55,7 +55,7 @@ class APIDownloader(object):
5555
area_of_interest : gpd.GeoDataFrame, shapely geometry, or bounding box, optional
5656
The spatial boundary to subset. Bounding boxes should
5757
match GeoDataFrame.total_bounds style.
58-
auth_method :
58+
auth_method :
5959
6060
Attributes
6161
----------
@@ -72,17 +72,20 @@ class APIDownloader(object):
7272
def __init__(
7373
self,
7474
download_label="earthpy-downloads",
75-
project_dir=os.path.join(HOME, DATA_NAME),
7675
start_date=None, end_date=None,
7776
start_doy=None, end_doy=None, months=None, seasons=None,
7877
start_year=None, end_year=None,
7978
area_of_interest=None,
8079
resubmit_in_progress=False,
8180
auth_method='keyring'):
82-
81+
# Initialize project
82+
if 'EARTHPY_PROJECT_DATA_DIR' in os.environ:
83+
self.project_dir = os.environ['EARTHPY_PROJECT_DATA_DIR']
84+
else:
85+
self.project_dir = Project().project_dir
86+
8387
# Initialize file structure
8488
self.download_label = download_label
85-
self.project_dir = project_dir
8689
self.download_dir = os.path.join(self.project_dir, self.download_label)
8790
os.makedirs(self.download_dir, exist_ok=True)
8891
self.db_path = os.path.join(self.download_dir, 'db.sqlite')
@@ -104,8 +107,6 @@ def __init__(
104107
self.end_year = end_year
105108
self.area_of_interest = area_of_interest
106109

107-
108-
109110
self.auth_method = auth_method
110111

111112
# Set up task id
@@ -152,245 +153,4 @@ def _init_database(self):
152153
FOREIGN KEY(request_id) REFERENCES download_requests(request_id)
153154
);
154155
''')
155-
conn.commit()
156-
157-
158-
def appeears_request(
159-
self, endpoint,
160-
method='POST', req_json=None, stream=False,
161-
**parameters):
162-
"""
163-
Submits a request to the AppEEARS API
164-
165-
Parameters
166-
----------
167-
endpoint : str
168-
The API endpoint from
169-
https://appeears.earthdatacloud.nasa.gov/api/
170-
method : str
171-
HTTP method 'GET' or 'POST'
172-
json : dictlike, optional
173-
JSON to submit with the request (for the task endpoint)
174-
**parameters : dict, optional
175-
Named parameters to format into the endpoint
176-
"""
177-
178-
logging.info('Submitting {} request...'.format(endpoint))
179-
180-
kwargs = {
181-
'url': self.base_url + endpoint.format(**parameters),
182-
'headers': {'Authorization': self.auth_header}
183-
}
184-
if req_json:
185-
logging.debug('Submitting task with JSON\n{}'.format(
186-
json.dumps(req_json)))
187-
kwargs['json'] = req_json
188-
189-
# Stream file downloads
190-
if stream:
191-
kwargs['allow_redirects'] = True
192-
kwargs['stream'] = True
193-
194-
# Submit request
195-
response = requests.request(method=method, **kwargs)
196-
logging.debug('RESPONSE TEXT: \n{}'.format(response.text))
197-
response.raise_for_status()
198-
199-
logging.info('{} request successfully completed'.format(endpoint))
200-
201-
return response
202-
203-
204-
def login(self, service='NASA_EARTHDATA', username_id='NED_USERNAME'):
205-
"""
206-
Logs in to the AppEEARS API.
207-
208-
Login happens automatically when self.auth_header is
209-
requested. Call this function to use a customized
210-
service name in the keyring, or set the self._auth_header
211-
value manually for other custom situations.
212-
213-
Parameters
214-
----------
215-
service : str, optional
216-
The name under which to store the credential in keyring
217-
"""
218-
# Get username and password from keyring
219-
try:
220-
username = keyring.get_password(service, username_id)
221-
password = keyring.get_password(service, username)
222-
except:
223-
username = None
224-
password = None
225-
226-
# Get username and password from environment
227-
try:
228-
username = os.environ['EARTHDATA_USERNAME']
229-
password = os.environ['EARTHDATA_PASSWORD']
230-
except:
231-
username = None
232-
password = None
233-
234-
# Prompt user if no username or password is stored
235-
if (username is None) or (password is None):
236-
# Ask for the user's username and password
237-
username = input('NASA Earthdata Username: ')
238-
password = getpass.getpass('NASA Earthdata Password: ')
239-
try:
240-
keyring.set_password(service, username_id, username)
241-
keyring.set_password(service, username, password)
242-
except:
243-
pass
244-
245-
logging.info('Logging into AppEEARS API...')
246-
247-
# Set up authentication and submit login request
248-
login_resp = requests.post(
249-
self.base_url + 'login',
250-
auth=(username, password))
251-
login_resp.raise_for_status()
252-
253-
self._auth_header = (
254-
'{token_type} {token}'.format(**login_resp.json()))
255-
256-
logging.info(
257-
'Login successful. Auth Header: {}'.format(self._auth_header))
258-
259-
@property
260-
def auth_header(self):
261-
if not self._auth_header:
262-
self.login()
263-
return self._auth_header
264-
265-
@property
266-
def task_id(self):
267-
if not self._task_id:
268-
self.submit_task_request()
269-
return self._task_id
270-
271-
@property
272-
def task_status(self):
273-
if self._status != 'done':
274-
self.wait_for_task()
275-
return self._status
276-
277-
def submit_task_request(self):
278-
"""
279-
Submit task request for the object parameters
280-
281-
This function is automatically called when self.task_id
282-
is requested. Set self._task_id to override.
283-
"""
284-
# Task parameters
285-
task = {
286-
'task_type': 'area',
287-
'task_name': self.download_key,
288-
'params': {
289-
'dates': [
290-
{
291-
'startDate': self._start_date,
292-
'endDate': self._end_date
293-
}
294-
],
295-
'layers': [
296-
{
297-
'product': self._product,
298-
'layer': self._layer
299-
}
300-
],
301-
# Need subdivisions as json, not as a string
302-
"geo": json.loads(self._polygon.dissolve().envelope.to_json()),
303-
"output": {
304-
"format": {"type": "geotiff"},
305-
"projection": "geographic"
306-
}
307-
}
308-
}
309-
310-
if self._recurring:
311-
if self._year_range is None:
312-
raise ValueError(
313-
'Must supply year range for recurring dates')
314-
task['params']['dates'][0]['recurring'] = True
315-
task['params']['dates'][0]['yearRange'] = self._year_range
316-
317-
# Submit the task request
318-
task_response = self.appeears_request('task', req_json=task)
319-
320-
# Save task ID for later
321-
self._task_id = task_response.json()['task_id']
322-
with open(self.task_id_path, 'w') as task_id_file:
323-
task_id_file.write(self._task_id)
324-
325-
def wait_for_task(self):
326-
"""
327-
Waits for the AppEEARS service to prepare data subset
328-
"""
329-
self._status = 'initializing'
330-
while self._status != 'done':
331-
time.sleep(3)
332-
# Wait 20 seconds in between status checks
333-
if self._status != 'initializing':
334-
time.sleep(20)
335-
336-
# Check status
337-
status_response = self.appeears_request(
338-
'status/{task_id}', method='GET', task_id=self.task_id)
339-
340-
# Update status
341-
if 'progress' in status_response.json():
342-
self._status = status_response.json()['progress']['summary']
343-
elif 'status' in status_response.json():
344-
self._status = status_response.json()['status']
345-
346-
logging.info(self._status)
347-
logging.info('Task completed - ready for download.')
348-
349-
def download_files(self, cache=True):
350-
"""
351-
Streams all prepared file downloads
352-
353-
Parameters
354-
----------
355-
cache : bool
356-
Use cache to avoid repeat downloads
357-
"""
358-
status = self.task_status
359-
logging.info('Current task status: {}'.format(status))
360-
361-
# Get file download information
362-
bundle_response = self.appeears_request(
363-
'bundle/{task_id}',
364-
method='GET',
365-
task_id=self.task_id)
366-
367-
files = bundle_response.json()['files']
368-
369-
'{} files available for download'.format(len(files))
370-
371-
# Download files
372-
for file_info in files:
373-
# Get a stream to the bundle file
374-
response = self.appeears_request(
375-
'bundle/{task_id}/{file_id}',
376-
method='GET', task_id=self.task_id, stream=True,
377-
file_id=file_info['file_id'])
378-
379-
# Create a destination directory to store the file in
380-
filepath = os.path.join(self.data_dir, file_info['file_name'])
381-
if not os.path.exists(os.path.dirname(filepath)):
382-
os.makedirs(os.path.dirname(filepath))
383-
384-
# Write the file to the destination directory
385-
if os.path.exists(filepath) and cache:
386-
logging.info(
387-
'File at {} alreading exists. Skipping...'
388-
.format(filepath))
389-
else:
390-
logging.info('Downloading file {}'.format(filepath))
391-
with open(filepath, 'wb') as f:
392-
for data in response.iter_content(chunk_size=8192):
393-
f.write(data)
394-
395-
# Remove task id file when download is complete
396-
os.remove(self.task_id_path)
156+
conn.commit()

0 commit comments

Comments
 (0)