From 20155f5a12b59220d07a311bd1fb7a44da0d0738 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Sun, 2 Jun 2019 17:12:46 -0500 Subject: [PATCH 01/37] created mock ui --- stac_browser.py | 59 +++--- ui/catalog_loading_dialog.py | 67 +++++++ ...r_loading.ui => catalog_loading_dialog.ui} | 2 +- ui/preview.jpg | Bin 0 -> 157207 bytes ui/query_dialog.py | 39 ++++ ui/query_dialog.ui | 175 ++++++++++++++++++ ui/results_dialog.py | 93 ++++++++++ ui/results_dialog.ui | 148 +++++++++++++++ ui/stac_browser_dialog.py | 44 ----- ui/stac_browser_dialog_base.ui | 81 -------- ui/stac_browser_loading.py | 46 ----- 11 files changed, 547 insertions(+), 207 deletions(-) create mode 100644 ui/catalog_loading_dialog.py rename ui/{stac_browser_loading.ui => catalog_loading_dialog.ui} (97%) create mode 100644 ui/preview.jpg create mode 100644 ui/query_dialog.py create mode 100644 ui/query_dialog.ui create mode 100644 ui/results_dialog.py create mode 100644 ui/results_dialog.ui delete mode 100644 ui/stac_browser_dialog.py delete mode 100644 ui/stac_browser_dialog_base.ui delete mode 100644 ui/stac_browser_loading.py diff --git a/stac_browser.py b/stac_browser.py index 7803c75..9895bf3 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -1,38 +1,17 @@ # -*- coding: utf-8 -*- -""" -/*************************************************************************** - STACBrowser - A QGIS plugin - This plugin searches for and downloads assets from STAC catalogs - Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/ - ------------------- - begin : 2019-05-28 - git sha : $Format:%H$ - copyright : (C) 2019 by Kevin Booth - email : kevin@kb.gg - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ -""" + from PyQt5.QtCore import QSettings, QTranslator, qVersion, QCoreApplication from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QAction # Initialize Qt resources from file resources.py from .resources import * -# Import the code for the dialog -from .ui.stac_browser_dialog import STACBrowserDialog -from .ui.stac_browser_loading import STACBrowserLoading +# Load UI Dialog Windows +from .ui.catalog_loading_dialog import CatalogLoadingDialog +from .ui.query_dialog import QueryDialog +from .ui.results_dialog import ResultsDialog import os.path - class STACBrowser: """QGIS Plugin Implementation.""" @@ -162,7 +141,6 @@ def add_action( def initGui(self): """Create the menu entries and toolbar icons inside the QGIS GUI.""" - icon_path = ':/plugins/stac_browser/assets/icon.png' self.add_action( icon_path, @@ -184,20 +162,31 @@ def unload(self): def run(self): - """Run method that performs all the real work""" - # Create the dialog with elements (after translation) and keep reference # Only create GUI ONCE in callback, so that it will only load when the plugin is started if self.first_start == True: self.first_start = False - self.dlg = STACBrowserLoading() + self.dlg = CatalogLoadingDialog(on_finished=self.on_catalog_loading_finished) # show the dialog self.dlg.show() - # Run the dialog event loop result = self.dlg.exec_() - # See if OK was pressed + + def on_catalog_loading_finished(self, results): + self.dlg.close() + self.dlg = QueryDialog() + self.dlg.show() + + result = self.dlg.exec_() + if result: + self.on_search() + + def on_search(self): + self.dlg.close() + self.dlg = ResultsDialog() + self.dlg.show() + + result = self.dlg.exec_() if result: - # Do something useful here - delete the line containing pass and - # substitute with your code. - pass + self.dlg.close() + self.first_start = True diff --git a/ui/catalog_loading_dialog.py b/ui/catalog_loading_dialog.py new file mode 100644 index 0000000..f45ca98 --- /dev/null +++ b/ui/catalog_loading_dialog.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +import os +import threading +import time +import queue + +from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5 import uic +from PyQt5 import QtWidgets + +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'catalog_loading_dialog.ui')) + + +class CatalogLoadingDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, parent=None, on_finished=None): + """Constructor.""" + super(CatalogLoadingDialog, self).__init__(parent) + + self.on_finished = on_finished + self.setupUi(self) + + self.setFixedSize(self.size()) + + self.catalogs = ['a', 'b', 'c', 'd', 'e', 'f'] + + self.loading_thread = LoadCatalogsThread(self.catalogs, + on_progress=self.on_progress_update, + on_finished=self.on_loading_finished) + + self.loading_thread.start() + + def on_progress_update(self, progress): + self.progressBar.setValue(int(progress*100)) + + def on_loading_finished(self): + self.progressBar.setValue(100) + + if self.on_finished is None: + return + + self.on_finished(None) + +class LoadCatalogsThread(QThread): + progress_signal = pyqtSignal(float) + finished_signal = pyqtSignal() + + def __init__(self, catalogs, on_progress=None, on_finished=None): + QThread.__init__(self) + self.catalogs = catalogs + self.on_progress=on_progress + self.on_finished=on_finished + + self.progress_signal.connect(self.on_progress) + self.finished_signal.connect(self.on_finished) + + def __del__(self): + self.wait() + + def run(self): + for i, k in enumerate(self.catalogs): + percent_finished = float(i) / float(len(self.catalogs)) + self.progress_signal.emit(percent_finished) + time.sleep(0.25) + + self.finished_signal.emit() diff --git a/ui/stac_browser_loading.ui b/ui/catalog_loading_dialog.ui similarity index 97% rename from ui/stac_browser_loading.ui rename to ui/catalog_loading_dialog.ui index 1244edc..7414508 100644 --- a/ui/stac_browser_loading.ui +++ b/ui/catalog_loading_dialog.ui @@ -42,7 +42,7 @@ - 100 + 0 diff --git a/ui/preview.jpg b/ui/preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4ee9d98f99e1f6c15f133b6b9a1ed6457cff10ed GIT binary patch literal 157207 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 literal 0 HcmV?d00001 diff --git a/ui/query_dialog.py b/ui/query_dialog.py new file mode 100644 index 0000000..cc0194c --- /dev/null +++ b/ui/query_dialog.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +import os + +from PyQt5 import uic +from PyQt5 import QtWidgets +from PyQt5 import QtCore +from PyQt5.QtGui import QStandardItemModel, QStandardItem + +# This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'query_dialog.ui')) + + +class QueryDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, parent=None): + """Constructor.""" + super(QueryDialog, self).__init__(parent) + # Set up the user interface from Designer through FORM_CLASS. + # After self.setupUi() you can access any designer object by doing + # self., and you can use autoconnect slots - see + # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html + # #widgets-and-dialogs-with-auto-connect + self.setupUi(self) + + now = QtCore.QDateTime.currentDateTimeUtc() + + self.endPeriod.setDateTime(now) + self.setFixedSize(self.size()) + + model = QStandardItemModel(self.list) + + collections = ["Canada AAFC Annual Crop Inventory", "AHN Netherlands 0.5m DEM, Interpolated", "AHN Netherlands 0.5m DEM, Non-Interpolated", "AHN Netherlands 0.5m DEM, Raw Samples", "ASTER L1T Radiance", "Australian 5M DEM", "DEM-H: Australian SRTM Hydrologically Enforced Digital Elevation Model", "DEM-S: Australian Smoothed Digital Elevation Model", "PML_V2: Coupled Evapotranspiration and Gross Primary Product", "SRTM Digital Elevation Data Version 4", "GPWv4: Gridded Population of the World Version 4, Ancillary Data Grids", "GPWv4: Gridded Population of the World Version 4, Population Count", "GPWv4: Gridded Population of the World Version 4, Population Density", "GPWv4: Gridded Population of the World Version 4, UN-Adjusted Population Count", "GPWv4: Gridded Population of the World Version 4, UN-Adjusted Population Density", "Copernicus CORINE Land Cover", "Sentinel-1 SAR GRD: C-band Synthetic Aperture Radar Ground Range Detected, log scaling", "Sentinel-2 MSI: MultiSpectral Instrument, Level-1C", "Sentinel-2 MSI: MultiSpectral Instrument, Level-2A", "Sentinel-3 OLCI EFR: Ocean and Land Color Instrument Earth Observation Full Resolution", "Sentinel-5P NRTI AER AI: Near Real-Time UV Aerosol Index", "Sentinel-5P NRTI CLOUD: Near Real-Time Cloud Dataset\n", "Sentinel-5P NRTI CO: Near Real-Time Carbon Monoxide Data", "Sentinel-5P NRTI HCHO: Near Real-Time Formaldehyde Data", "Sentinel-5P NRTI NO2: Near Real-Time Nitrogen Dioxide Data", "Sentinel-5P NRTI O3: Near Real Time Ozone Data", "Sentinel-5P NRTI SO2: Near Real-Time Sulphur Dioxide Data", "Sentinel-5P OFFL AER AI: Offline UV Aerosol Index", "Sentinel-5P OFFL CLOUD: Sentinel 5 Precursor Tropospheric (S5P/TROPOMI)\nOffline Cloud Data\n", "Sentinel-5P OFFL CO: Offline Carbon Monoxide Data", "Sentinel-5P OFFL HCHO: Offline Formaldehyde Data", "Sentinel-5P OFFL NO2: Offline Nitrogen Dioxide Data", "Sentinel-5P OFFL O3: Offline Ozone Data\n", "Sentinel-5P OFFL SO2: Offline Sulphur Dioxide Data", "CryoSat-2 Antarctica 1km DEM", "SLGA: Soil and Landscape Grid of Australia (Soil Attributes)", "Global ALOS CHILI (Continuous Heat-Insolation Load Index)", "Global ALOS Landforms", "Global ALOS mTPI (Multi-Scale Topographic Position Index)", "Global ALOS Topographic Diversity", "Global SRTM CHILI (Continuous Heat-Insolation Load Index)", "Global SRTM Landforms", "Global SRTM mTPI (Multi-Scale Topographic Position Index)", "Global SRTM Topographic Diversity", "US NED CHILI (Continuous Heat-Insolation Load Index)", "US NED Landforms", "US Lithology", "US NED mTPI (Multi-Scale Topographic Position Index)", "US NED Physiographic Diversity", "US Physiography", "US NED Topographic Diversity", "CSP gHM: Global Human Modification", "EO-1 Hyperion Hyperspectral Imager", "US EPA Ecoregions (Level III)", "US EPA Ecoregions (Level IV)", "GlobCover: Global Land Cover Map", "FAO GAUL: Global Administrative Unit Layers 2015, Country Boundaries", "FAO GAUL: Global Administrative Unit Layers 2015, First-Level Administrative Units", "FAO GAUL: Global Administrative Unit Layers 2015, Second-Level Administrative Units", "FIRMS: Fire Information for Resource Management System", "GFW (Global Fishing Watch) Daily Fishing Hours", "GFW (Global Fishing Watch) Daily Vessel Hours", "GLCF: Landsat Tree Cover Continuous Fields", "GLCF: Landsat Global Inland Water", "GLIMS: Global Land Ice Measurements from Space - 2016", "GLIMS: Global Land Ice Measurements from Space - 2017", "GLIMS: Global Land Ice Measurements from Space - current", "HYCOM: Hybrid Coordinate Ocean Model, Sea Surface Elevation", "HYCOM: Hybrid Coordinate Ocean Model, Water Temperature and Salinity", "HYCOM: Hybrid Coordinate Ocean Model, Water Velocity", "GRIDMET: University of Idaho Gridded Surface Meteorological Dataset", "MACAv2-METDATA: University of Idaho, Multivariate Adaptive Constructed Analogs Applied to Global Climate Models", "MACAv2-METDATA Monthly Summaries: University of Idaho, Multivariate Adaptive Constructed Analogs Applied to Global Climate Models", "PDSI: University of Idaho Palmer Drought Severity Index", "TerraClimate: Monthly Climate and Climatic Water Balance for Global Terrestrial Surfaces, University of Idaho", "ALOS/AVNIR-2 ORI", "ALOS DSM: Global 30m", "Global PALSAR-2/PALSAR Forest/Non-Forest Map", "Global PALSAR-2/PALSAR Yearly Mosaic", "GSMaP Operational: Global Satellite Mapping of Precipitation", "GSMaP Reanalysis: Global Satellite Mapping of Precipitation", "GHSL: Global Human Settlement Layers, Built-Up Grid 1975-1990-2000-2015 (P2016)", "GHSL: Global Human Settlement Layers, Population Grid 1975-1990-2000-2015 (P2016)", "GHSL: Global Human Settlement Layers, Settlement Grid 1975-1990-2000-2014 (P2016)", "JRC Global Surface Water Mapping Layers, v1.0", "JRC Global Surface Water Metadata, v1.0", "JRC Monthly Water History, v1.0", "JRC Monthly Water Recurrence, v1.0", "JRC Yearly Water Classification History, v1.0", "Landsat Global Land Survey 1975 Mosaic", "Landsat Global Land Survey 1975 Mosaic", "Landsat Global Land Survey 2005, Landsat 5+7 scenes", "Landsat Global Land Survey 2005, Landsat 5 scenes", "Landsat Global Land Survey 2005, Landsat 7 scenes", "USGS Landsat 8 Collection 1 Tier 1 Raw Scenes", "Landsat 8 Collection 1 Tier 1 32-Day BAI Composite", "Landsat 8 Collection 1 Tier 1 32-Day EVI Composite", "Landsat 8 Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 8 Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 8 Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 8 Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 8 Collection 1 Tier 1 32-Day Raw Composite", "Landsat 8 Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 8 Collection 1 Tier 1 8-Day BAI Composite", "Landsat 8 Collection 1 Tier 1 8-Day EVI Composite", "Landsat 8 Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 8 Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 8 Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 8 Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 8 Collection 1 Tier 1 8-Day Raw Composite", "Landsat 8 Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 8 Collection 1 Tier 1 Annual BAI Composite", "Landsat 8 Collection 1 Tier 1 Annual EVI Composite", "Landsat 8 Collection 1 Tier 1 Landsat 8 Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 8 Collection 1 Tier 1 Annual NBRT Composite", "Landsat 8 Collection 1 Tier 1 Annual NDSI Composite", "Landsat 8 Collection 1 Tier 1 Annual NDVI Composite", "Landsat 8 Collection 1 Tier 1 Annual NDWI Composite", "Landsat 8 Collection 1 Tier 1 Annual Raw Composite", "Landsat 8 Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 8 Collection 1 Tier 1 and Real-Time data Raw Scenes", "USGS Landsat 8 Collection 1 Tier 1 and Real-Time data TOA Reflectance", "USGS Landsat 8 Surface Reflectance Tier 1", "USGS Landsat 8 Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 8 Collection 1 Tier 2 Raw Scenes", "USGS Landsat 8 Surface Reflectance Tier 2", "USGS Landsat 8 Collection 1 Tier 2 TOA Reflectance", "USGS Landsat 7 Collection 1 Tier 1 Raw Scenes", "Landsat 7 Collection 1 Tier 1 32-Day BAI Composite", "Landsat 7 Collection 1 Tier 1 32-Day EVI Composite", "Landsat 7 Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 7 Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 7 Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 7 Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 7 Collection 1 Tier 1 32-Day Raw Composite", "Landsat 7 Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 7 Collection 1 Tier 1 8-Day BAI Composite", "Landsat 7 Collection 1 Tier 1 8-Day EVI Composite", "Landsat 7 Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 7 Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 7 Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 7 Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 7 Collection 1 Tier 1 8-Day Raw Composite", "Landsat 7 Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 7 Collection 1 Tier 1 Annual BAI Composite", "Landsat 7 Collection 1 Tier 1 Annual EVI Composite", "Landsat 7 Collection 1 Tier 1 Landsat 7 Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 7 Collection 1 Tier 1 Annual NBRT Composite", "Landsat 7 Collection 1 Tier 1 Annual NDSI Composite", "Landsat 7 Collection 1 Tier 1 Annual NDVI Composite", "Landsat 7 Collection 1 Tier 1 Annual NDWI Composite", "Landsat 7 Collection 1 Tier 1 Annual Raw Composite", "Landsat 7 Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 7 Collection 1 Tier 1 and Real-Time data Raw Scenes", "USGS Landsat 7 Collection 1 Tier 1 and Real-Time data TOA Reflectance", "USGS Landsat 7 Surface Reflectance Tier 1", "USGS Landsat 7 Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 7 Collection 1 Tier 2 Raw Scenes", "USGS Landsat 7 Surface Reflectance Tier 2", "USGS Landsat 7 Collection 1 Tier 2 TOA Reflectance", "Landsat 7 annual TOA percentile composites", "Landsat 7 3-year TOA percentile composites", "Landsat 7 5-year TOA percentile composites", "USGS Landsat 1 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 1 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 2 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 2 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 3 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 3 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 4 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 4 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 5 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 5 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 4 TM Collection 1 Tier 1 Raw Scenes", "Landsat 4 TM Collection 1 Tier 1 32-Day BAI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day EVI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day Raw Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day BAI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day EVI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day Raw Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 4 TM Collection 1 Tier 1 Annual BAI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual EVI Composite", "Landsat 4 TM Collection 1 Tier 1 Landsat 4 TM Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NBRT Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NDSI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NDVI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NDWI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual Raw Composite", "Landsat 4 TM Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 4 Surface Reflectance Tier 1", "USGS Landsat 4 TM Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 4 TM Collection 1 Tier 2 Raw Scenes", "USGS Landsat 4 Surface Reflectance Tier 2", "USGS Landsat 4 TM Collection 1 Tier 2 TOA Reflectance", "USGS Landsat 5 TM Collection 1 Tier 1 Raw Scenes", "Landsat 5 TM Collection 1 Tier 1 32-Day BAI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day EVI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day Raw Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day BAI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day EVI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day Raw Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 5 TM Collection 1 Tier 1 Annual BAI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual EVI Composite", "Landsat 5 TM Collection 1 Tier 1 Landsat 5 TM Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NBRT Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NDSI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NDVI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NDWI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual Raw Composite", "Landsat 5 TM Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 5 Surface Reflectance Tier 1", "USGS Landsat 5 TM Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 5 TM Collection 1 Tier 2 Raw Scenes", "USGS Landsat 5 Surface Reflectance Tier 2", "USGS Landsat 5 TM Collection 1 Tier 2 TOA Reflectance", "Global Mangrove Forests Distribution, v1 (2000)", "MCD12Q1.006 MODIS Land Cover Type Yearly Global 500m", "MCD15A3H.006 MODIS Leaf Area Index/FPAR 4-Day Global 500m", "MCD43A1.006 MODIS BRDF-Albedo Model Parameters Daily 500m", "MCD43A2.006 MODIS BRDF-Albedo Quality Daily 500m", "MCD43A3.006 MODIS Albedo Daily 500m", "MCD43A4.006 MODIS Nadir BRDF-Adjusted Reflectance, daily 500m", "MCD64A1.006 MODIS Burned Area Monthly Global 500m", "MOD08_M3.006 Terra Atmosphere Monthly Global Product", "MOD09A1.006 Terra Surface Reflectance 8-Day Global 500m", "MOD09GA.006 Terra Surface Reflectance Daily L2G Global 1km and 500m", "MOD09GQ.006 Terra Surface Reflectance Daily Global 250m", "MOD09Q1.006 Terra Surface Reflectance 8-Day Global 250m", "MOD10A1.006 Terra Snow Cover Daily Global 500m", "MOD11A1.006 Terra Land Surface Temperature and Emissivity Daily Global 1km", "MOD11A2.006 Terra Land Surface Temperature and Emissivity 8-Day Global 1km", "MOD13A1.006 Terra Vegetation Indices 16-Day Global 500m", "MOD13A2.006 Terra Vegetation Indices 16-Day Global 1km", "MOD13Q1.006 Terra Vegetation Indices 16-Day Global 250m", "MOD14A1.006: Terra Thermal Anomalies & Fire Daily Global 1km", "MOD14A2.006: Terra Thermal Anomalies & Fire 8-Day Global 1km", "MOD16A2.006: Terra Net Evapotranspiration 8-Day Global 500m", "MOD17A2H.006: Terra Gross Primary Productivity 8-Day Global 500m", "MOD17A3H.006: Terra Net Primary Production Yearly Global 500m", "MOD44W.006 Terra Land Water Mask Derived from MODIS and SRTM Yearly Global 250m", "MODOCGA.006 Terra Ocean Reflectance Daily Global 1km", "MYD08_M3.006 Aqua Atmosphere Monthly Global Product", "MYD09A1.006 Aqua Surface Reflectance 8-Day Global 500m", "MYD09GA.006 Aqua Surface Reflectance Daily L2G Global 1km and 500m", "MYD09GQ.006 Aqua Surface Reflectance Daily Global 250m", "MYD09Q1.006 Aqua Surface Reflectance 8-Day Global 250m", "MYD10A1.006 Aqua Snow Cover Daily Global 500m", "MYD11A1.006 Aqua Land Surface Temperature and Emissivity Daily Global 1km", "MYD11A2.006 Aqua Land Surface Temperature and Emissivity 8-Day Global 1km", "MYD13A1.006 Aqua Vegetation Indices 16-Day Global 500m", "MYD13A2.006 Aqua Vegetation Indices 16-Day Global 1km", "MYD13Q1.006 Aqua Vegetation Indices 16-Day Global 250m", "MYD14A1.006: Aqua Thermal Anomalies & Fire Daily Global 1km", "MYD14A2.006: Aqua Thermal Anomalies & Fire 8-Day Global 1km", "MYD17A2H.006: Aqua Gross Primary Productivity 8-Day Global 500m", "MYD17A3H.006: Aqua Net Primary Production Yearly Global 500m", "MYDOCGA.006 Aqua Ocean Reflectance Daily Global 1km", "MCD12Q1.051 Land Cover Type Yearly Global 500m", "MCD45A1.051 Burned Area Monthly Global 500m", "MOD44B.051 Terra Vegetation Continuous Fields Yearly Global 250m", "MOD17A3.055: Terra Net Primary Production Yearly Global 1km", "MCD12Q2.005 Land Cover Dynamics Yearly Global 500m", "MODIS Combined 16-Day BAI", "MODIS Combined 16-Day EVI", "MODIS Combined 16-Day NDSI", "MODIS Combined 16-Day NDVI", "MODIS Combined 16-Day NDWI", "MCD43B3.005 Albedo 16-Day Global 1km", "MODIS Terra Daily BAI", "MODIS Terra Daily EVI", "MODIS Terra Daily NDSI", "MODIS Terra Daily NDVI", "MODIS Terra Daily NDWI", "MOD13A1.005 Vegetation Indices 16-Day L3 Global 500m", "MOD44W.005 Land Water Mask Derived from MODIS and SRTM", "MODIS Aqua Daily BAI", "MODIS Aqua Daily EVI", "MODIS Aqua Daily NDSI", "MODIS Aqua Daily NDVI", "MODIS Aqua Daily NDWI", "MYD13A1.005 Vegetation Indices 16-Day L3 Global 500m", "MOD16A2: MODIS Global Terrestrial Evapotranspiration 8-Day Global 1km", "AG100: ASTER Global Emissivity Dataset 100-meter V003", "FLDAS: Famine Early Warning Systems Network (FEWS NET) Land Data\nAssimilation System\n", "GIMMS NDVI from AVHRR Sensors (3rd Generation)", "GLDAS-2: Global Land Data Assimilation System", "GLDAS-2.1: Global Land Data Assimilation System", "Reprocessed GLDAS-2: Global Land Data Assimilation System", "GPM: Global Precipitation Measurement (GPM) v5", "GRACE Monthly Mass Grids - Land", "GRACE Monthly Mass Grids - Global Mascons", "GRACE Monthly Mass Grids - Global Mascon (CRI Filtered)", "GRACE Monthly Mass Grids - Ocean", "GRACE Monthly Mass Grids - Ocean EOFR", "Global Forest Canopy Height, 2005", "NEX-DCP30: NASA Earth Exchange Downscaled Climate Projections", "NEX-DCP30: Ensemble Stats for NASA Earth Exchange Downscaled Climate Projections", "NEX-GDDP: NASA Earth Exchange Global Daily Downscaled Climate Projections", "NLDAS-2: North American Land Data Assimilation System Forcing Fields", "Ocean Color SMI: Standard Mapped Image MODIS Aqua Data", "Ocean Color SMI: Standard Mapped Image MODIS Terra Data", "Ocean Color SMI: Standard Mapped Image SeaWiFS Data", "Daymet V3: Daily Surface Weather and Climatological Summaries", "NASA-USDA SMAP Global Soil Moisture Data", "NASA-USDA Global Soil Moisture Data", "NCEP/NCAR Reanalysis Data, Sea-Level Pressure", "NCEP/NCAR Reanalysis Data, Surface Temperature", "NCEP/NCAR Reanalysis Data, Water vapor", "NOAA CDR: Ocean Near-Surface Atmospheric Properties, Version 2", "NOAA CDR AVHRR AOT: Daily Aerosol Optical Thickness Over Global Oceans, v03", "NOAA CDR AVHRR LAI FAPAR: Leaf Area Index and Fraction of Absorbed Photosynthetically Active Radiation, Version 4", "NOAA CDR AVHRR NDVI: Normalized Difference Vegetation Index, Version 4", "NOAA CDR AVHRR: Surface Reflectance, Version 4", "NOAA CDR GRIDSAT-B1: Geostationary IR Channel Brightness Temperature", "NOAA CDR: Ocean Heat Fluxes, Version 2", "NOAA CDR OISST: Optimum Interpolation Sea Surface Temperature", "NOAA CDR PATMOSX: Cloud Properties, Reflectance, and Brightness Temperatures, Version 5.3", "NOAA AVHRR Pathfinder Version 5.3 Collated Global 4km Sea Surface Temperature", "NOAA CDR WHOI: Sea Surface Temperature, Version 2", "CFSV2: NCEP Climate Forecast System Version 2, 6-Hourly Products", "DMSP OLS: Global Radiance-Calibrated Nighttime Lights Version 4, Defense Meteorological Program Operational Linescan System", "DMSP OLS: Nighttime Lights Time Series Version 4, Defense Meteorological Program Operational Linescan System", "GFS: Global Forecast System 384-Hour Predicted Atmosphere Data", "ETOPO1: Global 1 Arc-Minute Elevation", "RTMA: Real-Time Mesoscale Analysis", "PERSIANN-CDR: Precipitation Estimation from Remotely Sensed Information Using Artificial Neural Networks-Climate Data Record", "VNP09GA: VIIRS Surface Reflectance Daily 500m and 1km", "VNP13A1: VIIRS Vegetation Indices 16-Day 500m", "VIIRS Nighttime Day/Night Band Composites Version 1", "VIIRS Stray Light Corrected Nighttime Day/Night Band Composites Version 1", "Canadian Digital Elevation Model", "PRISM Daily Spatial Climate Dataset AN81d", "PRISM Monthly Spatial Climate Dataset AN81m", "PRISM Long-Term Average Climate Dataset Norm81m", "Greenland Ice & Ocean Mask - Greenland Mapping Project (GIMP)", "2000 Greenland Mosaic - Greenland Ice Mapping Project (GIMP)", "Greenland DEM - Greenland Mapping Project (GIMP)", "MEaSUREs Greenland Ice Velocity: Selected Glacier Site Velocity Maps from Optical Images Version 2", "Oxford MAP EVI: Malaria Atlas Project Gap-Filled Enhanced Vegetation Index", "Oxford MAP: Malaria Atlas Project Fractional International Geosphere-Biosphere Programme Landcover", "Oxford MAP LST: Malaria Atlas Project Gap-Filled Daytime Land Surface Temperature", "Oxford MAP LST: Malaria Atlas Project Gap-Filled Nighttime Land Surface Temperature", "Oxford MAP TCB: Malaria Atlas Project Gap-Filled Tasseled Cap Brightness", "Oxford MAP TCW: Malaria Atlas Project Gap-Filled Tasseled Cap Wetness", "Accessibility to Cities 2015", "Global Friction Surface 2015", "RESOLVE Ecoregions 2017", "Planet SkySat Public Ortho Imagery, Multispectral", "Planet SkySat Public Ortho Imagery, RGB", "TIGER: US Census Blocks", "TIGER: US Census Tracts Demographic - Profile 1", "TIGER: US Census Counties 2016", "TIGER: US Census Roads", "TIGER: US Census States 2016", "TOMS and OMI Merged Ozone Data", "TRMM 3B42: 3-Hourly Precipitation Estimates", "TRMM 3B43: Monthly Precipitation Estimates", "CHIRPS Daily: Climate Hazards Group InfraRed Precipitation with Station Data (version 2.0 final)", "CHIRPS Pentad: Climate Hazards Group InfraRed Precipitation with Station Data (version 2.0 final)", "Hansen Global Forest Change v1.6 (2000-2018)", "ArcticDEM Strips", "ArcticDEM Mosaic", "Landsat Gross Primary Production CONUS", "Landsat Net Primary Production CONUS", "MODIS Gross Primary Production CONUS", "MODIS Net Primary Production CONUS", "Murray Global Intertidal Change Data Mask", "Murray Global Intertidal Change Classification", "Murray Global Intertidal Change QA Pixel Count", "NAIP: National Agriculture Imagery Program", "USDA NASS Cropland Data Layers", "LSIB: Large Scale International Boundary Polygons, Detailed", "LSIB: Large Scale International Boundary Polygons, Simplified", "GFSAD1000: Cropland Extent 1km Crop Dominance, Global Food-Support Analysis Data", "GFSAD1000: Cropland Extent 1km Multi-Study Crop Mask, Global Food-Support Analysis Data", "GMTED2010: Global Multi-resolution Terrain Elevation Data 2010", "GTOPO30: Global 30 Arc-Second Elevation", "Landsat Image Mosaic of Antarctica (LIMA) 16-Bit Pan-sharpened Mosaic", "Landsat Image Mosaic of Antarctica (LIMA) - Processed Landsat Scenes (16 bit)", "USGS National Elevation Dataset 1/3 arc-second", "NLCD: USGS National Land Cover Database", "SRTM Digital Elevation Data 30m", "HUC02: USGS Watershed Boundary Dataset of Regions", "HUC04: USGS Watershed Boundary Dataset of Subregions", "HUC06: USGS Watershed Boundary Dataset of Basins", "HUC08: USGS Watershed Boundary Dataset of Subbasins", "HUC10: USGS Watershed Boundary Dataset of Watersheds", "HUC12: USGS Watershed Boundary Dataset of Subwatersheds", "KBDI: Keetch-Byram Drought Index", "PROBA-V C1 Top Of Canopy Daily Synthesis 100m", "PROBA-V C1 Top Of Canopy Daily Synthesis 333m", "WDPA: World Database on Protected Areas (points)", "WDPA: World Database on Protected Areas (polygons)", "MODIS 1-year NBAR Mosaic", "MODIS 2-year NBAR Mosaic", "MODIS 3-year NBAR Mosaic", "WHRC Pantropical National Level Carbon Stock Dataset", "WorldClim BIO Variables V1", "WorldClim Climatology V1", "FORMA Alerts", "FORMA Raw Output FIRMS", "FORMA Raw Output NDVI", "FORMA alert thresholds", "FORMA Vegetation T-Statistics", "Global Power Plant Database", "WWF HydroSHEDS Hydrologically Conditioned DEM, 3 Arc-Seconds", "WWF HydroSHEDS Drainage Direction, 3 Arc-Seconds", "WWF HydroSHEDS Void-Filled DEM, 3 Arc-Seconds", "WWF HydroSHEDS Flow Accumulation, 15 Arc-Seconds", "WWF HydroSHEDS Hydrologically Conditioned DEM, 15 Arc-Seconds", "WWF HydroSHEDS Drainage Direction, 15 Arc-Seconds", "WWF HydroSHEDS Flow Accumulation, 30 Arc-Seconds", "WWF HydroSHEDS Hydrologically Conditioned DEM, 30 Arc-Seconds", "WWF HydroSHEDS Drainage Direction, 30 Arc-Seconds", "WorldPop Project Population Data: Estimated Residential Population per 100x100m Grid Square"] + for collection in sorted(collections): + item = QStandardItem(collection.replace('\n', ' ')) + item.setCheckable(True) + model.appendRow(item) + + self.list.setModel(model) diff --git a/ui/query_dialog.ui b/ui/query_dialog.ui new file mode 100644 index 0000000..2a299fa --- /dev/null +++ b/ui/query_dialog.ui @@ -0,0 +1,175 @@ + + + STACBrowserDialogBase + + + + 0 + 0 + 730 + 270 + + + + STAC Browser + + + + + 10 + 10 + 441 + 250 + + + + QFrame::NoFrame + + + QFrame::Sunken + + + true + + + + + 0 + 0 + 441 + 250 + + + + + + 0 + 0 + 441 + 250 + + + + 0 + + + + + + + + 460 + 10 + 261 + 241 + + + + + + + Extent Layer + + + + + + + + + Qt::LeftToRight + + + ... + + + + + + + true + + + + + + + + + Time Period + + + + + + + yyyy-MM-dd HH:mmZ + + + + + + + to + + + Qt::AlignCenter + + + + + + + yyyy-MM-dd HH:mmZ + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + button_box + accepted() + STACBrowserDialogBase + accept() + + + 20 + 20 + + + 20 + 20 + + + + + button_box + rejected() + STACBrowserDialogBase + reject() + + + 20 + 20 + + + 20 + 20 + + + + + diff --git a/ui/results_dialog.py b/ui/results_dialog.py new file mode 100644 index 0000000..2b3d1c6 --- /dev/null +++ b/ui/results_dialog.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +import os + +from PyQt5 import uic +from PyQt5 import QtWidgets +from PyQt5 import QtCore +from PyQt5 import QtGui + +# This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'results_dialog.ui')) + + +class ResultsDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, parent=None): + """Constructor.""" + super(ResultsDialog, self).__init__(parent) + # Set up the user interface from Designer through FORM_CLASS. + # After self.setupUi() you can access any designer object by doing + # self., and you can use autoconnect slots - see + # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html + # #widgets-and-dialogs-with-auto-connect + self.setupUi(self) + + self.setFixedSize(self.size()) + + model = QtGui.QStandardItemModel(self.list) + preview_path = os.path.join(os.path.dirname(__file__), 'preview.jpg') + self.items = { + 'S2B_9VXK_20171013_0': { + 'thumbnail': preview_path, + 'properties': { + 'collection': 'sentinel-2-l1c', + 'datetime': '2017-10-13T20:03:46.461000+00:00', + 'eo:platform': 'sentinel-2b', + 'eo:cloud_cover': 41.52, + 'sentinel:utm_zone': 9, + 'sentinel:latitude_band': 'V', + 'sentinel:grid_square': 'XK', + 'sentinel:sequence': '0', + 'sentinel:product_id': 'S2B_MSIL1C_20171013T200349_N0205_R128_T09VXK_20171013T200346' + } + }, + 'S2B_9VXK_20171014_0': { + 'thumbnail': None, + 'properties': { + + } + }, + 'S2B_9VXK_20171015_0': { + 'thumbnail': preview_path, + 'properties': { + + } + }, + } + + for collection in sorted(list(self.items.keys())): + item = QtGui.QStandardItem(collection.replace('\n', ' ')) + item.setCheckable(True) + model.appendRow(item) + + self.list.setModel(model) + + self.list.clicked.connect(self.on_list_clicked) + + @QtCore.pyqtSlot(QtCore.QModelIndex) + def on_list_clicked(self, index): + items = self.list.selectedIndexes() + item_id = sorted(list(self.items.keys()))[int(items[0].row())] + self.load_item(item_id) + + def load_item(self, item_id): + data = self.items[item_id] + if data['thumbnail'] is not None: + image_profile = QtGui.QImage(data['thumbnail']) + image_profile = image_profile.scaled(250, 250, + aspectRatioMode=QtCore.Qt.KeepAspectRatio, + transformMode=QtCore.Qt.SmoothTransformation) + self.imageView.setPixmap(QtGui.QPixmap.fromImage(image_profile)) + else: + self.imageView.setText('No Preview Available') + + property_keys = sorted(list(data['properties'].keys())) + + self.propertiesTable.setColumnCount(2) + self.propertiesTable.setRowCount(len(property_keys)) + + for i, key in enumerate(property_keys): + self.propertiesTable.setItem(i, 0, QtWidgets.QTableWidgetItem(key)) + self.propertiesTable.setItem(i, 1, QtWidgets.QTableWidgetItem(str(data['properties'][key]))) + self.propertiesTable.resizeColumnsToContents() diff --git a/ui/results_dialog.ui b/ui/results_dialog.ui new file mode 100644 index 0000000..55452e4 --- /dev/null +++ b/ui/results_dialog.ui @@ -0,0 +1,148 @@ + + + STACBrowserDialogBase + + + + 0 + 0 + 730 + 529 + + + + STAC Browser + + + + + 10 + 10 + 241 + 471 + + + + QFrame::NoFrame + + + QFrame::Sunken + + + true + + + + + 0 + 0 + 241 + 471 + + + + + + 0 + 0 + 240 + 470 + + + + 0 + + + + + + + + 290 + 270 + 430 + 210 + + + + QAbstractScrollArea::AdjustToContents + + + false + + + false + + + + + + 10 + 490 + 111 + 32 + + + + Select All + + + + + + 136 + 490 + 111 + 32 + + + + Deselect All + + + + + + 380 + 10 + 250 + 250 + + + + Click an item on the left to view details + + + Qt::AlignCenter + + + + + + 600 + 490 + 113 + 32 + + + + Download + + + + + + 490 + 494 + 111 + 20 + + + + Add To Layers + + + + + + diff --git a/ui/stac_browser_dialog.py b/ui/stac_browser_dialog.py deleted file mode 100644 index 63e88c5..0000000 --- a/ui/stac_browser_dialog.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -""" -/*************************************************************************** - STACBrowserDialog - A QGIS plugin - This plugin searches for and downloads assets from STAC catalogs - Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/ - ------------------- - begin : 2019-05-28 - git sha : $Format:%H$ - copyright : (C) 2019 by Kevin Booth - email : kevin@kb.gg - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ -""" - -import os - -from PyQt5 import uic -from PyQt5 import QtWidgets - -# This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'stac_browser_dialog_base.ui')) - - -class STACBrowserDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, parent=None): - """Constructor.""" - super(STACBrowserDialog, self).__init__(parent) - # Set up the user interface from Designer through FORM_CLASS. - # After self.setupUi() you can access any designer object by doing - # self., and you can use autoconnect slots - see - # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html - # #widgets-and-dialogs-with-auto-connect - self.setupUi(self) diff --git a/ui/stac_browser_dialog_base.ui b/ui/stac_browser_dialog_base.ui deleted file mode 100644 index 4f6ca59..0000000 --- a/ui/stac_browser_dialog_base.ui +++ /dev/null @@ -1,81 +0,0 @@ - - - STACBrowserDialogBase - - - - 0 - 0 - 400 - 300 - - - - STAC Browser - - - - - 30 - 240 - 341 - 32 - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - 120 - 120 - 111 - 16 - - - - Hello world! - - - - - - - button_box - accepted() - STACBrowserDialogBase - accept() - - - 20 - 20 - - - 20 - 20 - - - - - button_box - rejected() - STACBrowserDialogBase - reject() - - - 20 - 20 - - - 20 - 20 - - - - - diff --git a/ui/stac_browser_loading.py b/ui/stac_browser_loading.py deleted file mode 100644 index 2051cf1..0000000 --- a/ui/stac_browser_loading.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -""" -/*************************************************************************** - STACBrowserDialog - A QGIS plugin - This plugin searches for and downloads assets from STAC catalogs - Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/ - ------------------- - begin : 2019-05-28 - git sha : $Format:%H$ - copyright : (C) 2019 by Kevin Booth - email : kevin@kb.gg - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ -""" - -import os - -from PyQt5 import uic -from PyQt5 import QtWidgets - -# This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'stac_browser_loading.ui')) - - -class STACBrowserLoading(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, parent=None): - """Constructor.""" - super(STACBrowserLoading, self).__init__(parent) - # Set up the user interface from Designer through FORM_CLASS. - # After self.setupUi() you can access any designer object by doing - # self., and you can use autoconnect slots - see - # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html - # #widgets-and-dialogs-with-auto-connect - self.setupUi(self) - - self.setFixedSize(self.size()) From c0e5192e4c227e2ddaf130df8752ef58a2bfda6d Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Sun, 2 Jun 2019 17:17:05 -0500 Subject: [PATCH 02/37] wip --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..8efa492 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +stac-logo + +## About + +The SpatioTemporal Asset Catalog (STAC) specification aims to standardize the way geospatial assets are exposed online and queried. +A 'spatiotemporal asset' is any file that represents information about the earth captured in a certain space and +time. The initial focus is primarily remotely-sensed imagery (from satellites, but also planes, drones, balloons, etc), but +the core is designed to be extensible to SAR, full motion video, point clouds, hyperspectral, LiDAR and derived data like +NDVI, Digital Elevation Models, mosaics, etc. + +The goal is for all major providers of imagery and other earth observation data to expose their data as SpatioTemporal Asset +Catalogs, so that new code doesn't need to be written whenever a new JSON-based REST API comes out that makes its data +available in a slightly different way. This will enable standard library components in many languages. STAC can also be +implemented in a completely 'static' manner, enabling data publishers to expose their data by simply publishing linked JSON +files online. + +## Building + +To build the plugin and deploy to your plugin directory you will need the [pb_tool](http://g-sherman.github.io/plugin_build_tool/) CLI tool. + +To compile the plugin run `pb_tool compile` in the root directory of this repository. +Compiling is needed any time the resources.py file needs to be rebuilt. +To deploy the application to your QGIS plugins directory run `pb_tool` deploy and reload the plugin within QGIS. +It's recommended to use the Plugin Reloader plugin within QGIS to easily reload the plugin during development. +## Current version and branches + +The [master branch](https://github.com/kbgg/qgis-stac-browser/tree/master) is the 'stable' version of the spec. It is currently version +**0.1** of the plugin. The +[dev](https://github.com/kbgg/qgis-stac-browser/tree/dev) branch is where active development takes place. +Whenever dev stabilizes a release is cut and we merge dev in to master. So master should be stable at any given time. +It is possible that there may be small releases in quick succession, especially if they are nice improvements that do +not require lots of updating. + +## In this Repository + +This repository contains the core specifications plus examples and validation schemas and tools. Also included are a +few documents that provide more context and plans for the evolution of the specification. Each spec folder contains a +README explaining the layout of the folder, the main specification document, examples, validating schemas and OpenAPI +documents (if relevant). The four specifications detailed are meant to be used together, but are designed so each piece +is small, self-contained and reusable in other contexts. + +**[item-spec/](item-spec/)** defines a STAC Item, which is a [GeoJSON](http://geojson.org) Feature +with additional fields for things like time, links to related entities and assets (including thumbnails). This is the +atomic unit that describes the data to be discovered. + +**[catalog-spec/](catalog-spec/)** specifies a structure to link various STAC Items together to be crawled or browsed. It is a +simple, flexible JSON file of links to Items, Catalogs or Collections that can be used in a variety of ways. + +**[collection-spec/](collection-spec/)** provides additional information about a spatio-temporal collection of data. +In the context of STAC it is most likely a collection of STAC Items that is made available by a data provider. +It includes things like the spatial and temporal extent of the data, the license, keywords, etc. +It enables discovery at a higher level than individual items, providing a simple way to describe sets of data. + +**[api-spec/](api-spec/)** extends the core publishing capabilities of STAC with an active REST search endpoint that returns +just the Items a user requests in their query. It is specified as a couple [OpenAPI](http://openapis.org) documents, one +[standalone](api-spec/STAC-standalone.yaml) and one that is [integrated with WFS3](api-spec/WFS3core%2BSTAC.yaml) +(see [WFS3 on GitHub](https://github.com/opengeospatial/wfs_fes) for info on it). The documents also include the `/stac/` +endpoint which is a way for a dynamic server to provide catalog and collection browsing. + +**Extensions:** The *[extensions/](extensions/)* folder is where extensions live. Extensions can extend the +functionality of the core spec or add fields for specific domains. + +**Additional documents** include the current [roadmap](roadmap.md) and a complementary [how to help](how-to-help.md) +document, a [list of implementations](implementations.md), +and a discussion of the collaboration [principles](principles.md) and specification approach. + From 6685d8d377382607584cc99d873ec4539e50af62 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Sun, 2 Jun 2019 17:19:30 -0500 Subject: [PATCH 03/37] wip --- README.md | 47 +---------------------------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/README.md b/README.md index 8efa492..4eb5ace 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,7 @@ ## About -The SpatioTemporal Asset Catalog (STAC) specification aims to standardize the way geospatial assets are exposed online and queried. -A 'spatiotemporal asset' is any file that represents information about the earth captured in a certain space and -time. The initial focus is primarily remotely-sensed imagery (from satellites, but also planes, drones, balloons, etc), but -the core is designed to be extensible to SAR, full motion video, point clouds, hyperspectral, LiDAR and derived data like -NDVI, Digital Elevation Models, mosaics, etc. - -The goal is for all major providers of imagery and other earth observation data to expose their data as SpatioTemporal Asset -Catalogs, so that new code doesn't need to be written whenever a new JSON-based REST API comes out that makes its data -available in a slightly different way. This will enable standard library components in many languages. STAC can also be -implemented in a completely 'static' manner, enabling data publishers to expose their data by simply publishing linked JSON -files online. - +The QGIS STAC Browser plugin allows for searching STAC catalogs for assets and downloading those assets directly into QGIS. ## Building To build the plugin and deploy to your plugin directory you will need the [pb_tool](http://g-sherman.github.io/plugin_build_tool/) CLI tool. @@ -30,37 +19,3 @@ The [master branch](https://github.com/kbgg/qgis-stac-browser/tree/master) is th Whenever dev stabilizes a release is cut and we merge dev in to master. So master should be stable at any given time. It is possible that there may be small releases in quick succession, especially if they are nice improvements that do not require lots of updating. - -## In this Repository - -This repository contains the core specifications plus examples and validation schemas and tools. Also included are a -few documents that provide more context and plans for the evolution of the specification. Each spec folder contains a -README explaining the layout of the folder, the main specification document, examples, validating schemas and OpenAPI -documents (if relevant). The four specifications detailed are meant to be used together, but are designed so each piece -is small, self-contained and reusable in other contexts. - -**[item-spec/](item-spec/)** defines a STAC Item, which is a [GeoJSON](http://geojson.org) Feature -with additional fields for things like time, links to related entities and assets (including thumbnails). This is the -atomic unit that describes the data to be discovered. - -**[catalog-spec/](catalog-spec/)** specifies a structure to link various STAC Items together to be crawled or browsed. It is a -simple, flexible JSON file of links to Items, Catalogs or Collections that can be used in a variety of ways. - -**[collection-spec/](collection-spec/)** provides additional information about a spatio-temporal collection of data. -In the context of STAC it is most likely a collection of STAC Items that is made available by a data provider. -It includes things like the spatial and temporal extent of the data, the license, keywords, etc. -It enables discovery at a higher level than individual items, providing a simple way to describe sets of data. - -**[api-spec/](api-spec/)** extends the core publishing capabilities of STAC with an active REST search endpoint that returns -just the Items a user requests in their query. It is specified as a couple [OpenAPI](http://openapis.org) documents, one -[standalone](api-spec/STAC-standalone.yaml) and one that is [integrated with WFS3](api-spec/WFS3core%2BSTAC.yaml) -(see [WFS3 on GitHub](https://github.com/opengeospatial/wfs_fes) for info on it). The documents also include the `/stac/` -endpoint which is a way for a dynamic server to provide catalog and collection browsing. - -**Extensions:** The *[extensions/](extensions/)* folder is where extensions live. Extensions can extend the -functionality of the core spec or add fields for specific domains. - -**Additional documents** include the current [roadmap](roadmap.md) and a complementary [how to help](how-to-help.md) -document, a [list of implementations](implementations.md), -and a discussion of the collaboration [principles](principles.md) and specification approach. - From 32938e2ea0114b4706ea00f158fe89716f733a93 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Tue, 4 Jun 2019 10:57:31 -0700 Subject: [PATCH 04/37] added default list of APIs --- utils/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 utils/config.py diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..2039715 --- /dev/null +++ b/utils/config.py @@ -0,0 +1,8 @@ +class Config: + STAC_APIS = ['https://sat-api.developmentseed.org'] + + def __init__(self): + pass + + def get_api_list(self): + return self.STAC_APIS From 50d60d6d0406dca2f20f8da68adf56264702b563 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Tue, 4 Jun 2019 11:01:07 -0700 Subject: [PATCH 05/37] Updated endpoint --- utils/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/config.py b/utils/config.py index 2039715..ae7e399 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,5 +1,5 @@ class Config: - STAC_APIS = ['https://sat-api.developmentseed.org'] + STAC_APIS = ['https://sat-api.developmentseed.org/stac'] def __init__(self): pass From 2d161d895db9f1721a4b1c98123abb041264dad2 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Sat, 8 Jun 2019 16:34:51 -0500 Subject: [PATCH 06/37] Searching collections --- models/catalog.py | 68 ++++- models/collection.py | 38 ++- models/item.py | 60 +++- stac_browser.py | 238 +++++++-------- ui/catalog_loading_dialog.py | 67 ---- ui/collection_loading_dialog.py | 87 ++++++ ...dialog.ui => collection_loading_dialog.ui} | 0 ui/item_loading_dialog.py | 90 ++++++ ui/item_loading_dialog.ui | 37 +++ ui/preview.jpg | Bin 157207 -> 0 bytes ui/query_dialog.py | 88 ++++-- ui/query_dialog.ui | 274 +++++++---------- ui/results_dialog.py | 177 +++++++---- ui/results_dialog.ui | 288 ++++++++++-------- utils/config.py | 2 +- 15 files changed, 942 insertions(+), 572 deletions(-) delete mode 100644 ui/catalog_loading_dialog.py create mode 100644 ui/collection_loading_dialog.py rename ui/{catalog_loading_dialog.ui => collection_loading_dialog.ui} (100%) create mode 100644 ui/item_loading_dialog.py create mode 100644 ui/item_loading_dialog.ui delete mode 100644 ui/preview.jpg diff --git a/models/catalog.py b/models/catalog.py index a5d2707..ad2ac52 100644 --- a/models/catalog.py +++ b/models/catalog.py @@ -1,3 +1,67 @@ +import requests +import json +import math +from .collection import Collection +from .item import Item + class Catalog: - def __init__(self): - pass + def __init__(self, url=None): + self.url = url + self.data = None + + def get_data(self): + if self.data is None: + return {} + + return self.data + + def get_title(self): + return self.get_data().get('title', 'Unknown') + + def get_url(self): + return self.url + + def get_collections(self): + r = requests.get(f'{self.get_url()}/stac', verify=False) + self.data = r.json() + links = r.json().get('links', []) + collections = [] + + for link in links: + if link.get('rel', None) == 'child': + collection = Collection(parent=self, url=link.get('href', None)) + collections.append(collection) + + return collections + + def search_items(self, collections, extent, start_time, end_time, page=0, on_next_page=None): + collection_ids = [] + items = [] + for collection in collections: + collection_ids.append(collection.get_id()) + if on_next_page is not None: + on_next_page() + r = requests.post(f'{self.get_url()}/stac/search', + json={ + 'collections': collection_ids, + 'bbox': extent, + 'page': page, + 'limit': 100, + 'time': f'{start_time.strftime("%Y-%m-%dT%H:%M:%SZ")}/{end_time.strftime("%Y-%m-%dT%H:%M:%SZ")}' + }, verify=False) + data = r.json() + for feature in data['features']: + items.append(Item(data=feature)) + + search_meta = data['meta'] + max_page = math.ceil(search_meta['found'] / search_meta['limit'])-1 + if page < max_page: + more_items = self.search_items(collections, + extent, + start_time, + end_time, + page+1, + on_next_page=on_next_page) + items.extend(more_items) + + return items diff --git a/models/collection.py b/models/collection.py index 033181c..9d0ea3b 100644 --- a/models/collection.py +++ b/models/collection.py @@ -1,4 +1,38 @@ +import requests +import urllib.parse as urlparse +import math +from .item import Item + class Collection: - def __init__(self): - pass + def __init__(self, parent=None, url=None): + self.parent = parent + self.url = url + self.data = None + + def get_url(self): + return self.url + + def get_parent(self): + return self.parent + + def get_search_url(self): + return f'{self.get_url()}/items' + + def load(self): + r = requests.get(self.get_url(), verify=False) + self.data = r.json() + + def get_data(self): + if self.data is None: + return {} + + return self.data + + def get_title(self): + return self.get_data().get('title', 'Unknown') + + def get_id(self): + return self.get_data().get('id', 'N/A') + def __lt__(self, other): + return self.get_title() < other.get_title() diff --git a/models/item.py b/models/item.py index 26c09b6..f0214be 100644 --- a/models/item.py +++ b/models/item.py @@ -1,3 +1,61 @@ +import os +from pathlib import Path + class Item: - def __init__(self): + def __init__(self, data=None): + self.data = data + + def get_data(self): + if self.data is None: + return {} + + return self.data + + def get_id(self): + return self.get_data().get('id', 'N/A') + + def get_properties(self): + return self.get_data().get('properties', {}) + + def get_thumbnail_url(self): + thumbnail = self.get_data().get('assets', {}).get('thumbnail', None) + if thumbnail is None: + return None + return thumbnail.get('href', None) + + def get_temp_dir(self): + return os.path.join(Path(__file__).parent.parent, 'tmp') + + def thumbnail_downloaded(self): + return os.path.exists(self.get_thumbnail_path()) + + def get_thumbnail_path(self): + if not os.path.exists(self.get_temp_dir()): + os.makedirs(self.get_temp_dir()) + previews_dir = os.path.join(self.get_temp_dir(), 'previews') + + if not os.path.exists(previews_dir): + os.makedirs(previews_dir) + path = os.path.join(previews_dir, self.get_id()) + + return path + + def asset_downloaded(self): + return os.path.exists(self.get_asset_path()) + + def get_asset_path(self): + if not os.path.exists(self.get_temp_dir()): + os.makedirs(self.get_temp_dir()) + assets_dir = os.path.join(self.get_temp_dir(), 'assets') + + if not os.path.exists(assets_dir): + os.makedirs(assets_dir) + path = os.path.join(assets_dir, self.get_id()) + + return path + + def download(self): pass + + def __lt__(self, other): + return self.get_id() < other.get_id() diff --git a/stac_browser.py b/stac_browser.py index 9895bf3..17d69cd 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -1,69 +1,123 @@ -# -*- coding: utf-8 -*- - -from PyQt5.QtCore import QSettings, QTranslator, qVersion, QCoreApplication +from PyQt5.QtCore import QSettings, QCoreApplication from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QAction -# Initialize Qt resources from file resources.py from .resources import * -# Load UI Dialog Windows -from .ui.catalog_loading_dialog import CatalogLoadingDialog +from .ui.collection_loading_dialog import CollectionLoadingDialog from .ui.query_dialog import QueryDialog +from .ui.item_loading_dialog import ItemLoadingDialog from .ui.results_dialog import ResultsDialog import os.path class STACBrowser: - """QGIS Plugin Implementation.""" - def __init__(self, iface): - """Constructor. - - :param iface: An interface instance that will be passed to this class - which provides the hook by which you can manipulate the QGIS - application at run time. - :type iface: QgsInterface - """ - # Save reference to the QGIS interface self.iface = iface - # initialize plugin directory self.plugin_dir = os.path.dirname(__file__) - # initialize locale - locale = QSettings().value('locale/userLocale')[0:2] - locale_path = os.path.join( - self.plugin_dir, - 'i18n', - 'STACBrowser_{}.qm'.format(locale)) - - if os.path.exists(locale_path): - self.translator = QTranslator() - self.translator.load(locale_path) - - if qVersion() > '4.3.3': - QCoreApplication.installTranslator(self.translator) - # Declare instance attributes self.actions = [] - self.menu = self.tr(u'&STAC Browser') - - # Check if plugin was started the first time in current QGIS session - # Must be set in initGui() to survive plugin reloads - self.first_start = None - - # noinspection PyMethodMayBeStatic - def tr(self, message): - """Get the translation for a string using Qt translation API. - - We implement this ourselves since we do not inherit QObject. - - :param message: String for translation. - :type message: str, QString - - :returns: Translated version of message. - :rtype: QString - """ - # noinspection PyTypeChecker,PyArgumentList,PyCallByClass - return QCoreApplication.translate('STACBrowser', message) - + self.menu = u'&STAC Browser' + + self.current_window = 'COLLECTION_LOADING' + self.windows = { + 'COLLECTION_LOADING': { + 'class': CollectionLoadingDialog, + 'hooks': {'on_finished': self.collection_load_finished}, + 'data': None, + 'dialog': None + }, + 'QUERY': { + 'class': QueryDialog, + 'hooks': {'on_close': self.on_close, 'on_search': self.on_search}, + 'data': None, + 'dialog': None + }, + 'ITEM_LOADING': { + 'class': ItemLoadingDialog, + 'hooks': {'on_close': self.on_close, 'on_finished': self.item_load_finished}, + 'data': None, + 'dialog': None + }, + 'RESULTS': { + 'class': ResultsDialog, + 'hooks': {'on_close': self.on_close, 'on_back': self.on_back}, + 'data': None, + 'dialog': None + }, + } + + def on_search(self, collections, extent_layer, time_period): + (start_time, end_time) = time_period + + extent_rect = extent_layer.extent() + extent = [extent_rect.xMinimum(), extent_rect.yMinimum(), extent_rect.xMaximum(), extent_rect.yMaximum()] + + self.windows['ITEM_LOADING']['data'] = { + 'collections': collections, + 'extent': extent, + 'start_time': start_time, + 'end_time': end_time + } + self.current_window = 'ITEM_LOADING' + self.windows['QUERY']['dialog'].close() + self.load_window() + + def on_back(self): + self.windows['RESULTS']['data'] = None + self.windows['RESULTS']['dialog'].close() + self.windows['RESULTS']['dialog'] = None + + self.current_window = 'QUERY' + self.load_window() + + def on_close(self): + for key, window in self.windows.items(): + if window['dialog'] is not None: + window['dialog'].close() + + window['dialog'] = None + window['data'] = None + self.current_window = 'COLLECTION_LOADING' + + def load_window(self): + window = self.windows.get(self.current_window, None) + + if window is None: + print(f'Window {self.current_window} does not exist') + return + + if window['dialog'] is None: + window['dialog'] = window.get('class')(data=window.get('data'), hooks=window.get('hooks')) + window['dialog'].show() + else: + window['dialog'].raise_() + window['dialog'].show() + window['dialog'].activateWindow() + + def reset_windows(self): + for key, window in self.windows.items(): + window['data'] = None + window['dialog'] = None + + def collection_load_finished(self, collections): + collection_ids = [] + final_collections = [] + for collection in collections: + if f'{collection.get_id()}:{collection.get_parent().get_url()}' in collection_ids: + continue + collection_ids.append(f'{collection.get_id()}:{collection.get_parent().get_url()}') + final_collections.append(collection) + self.windows['QUERY']['data'] = { 'collections': final_collections } + self.current_window = 'QUERY' + self.windows['COLLECTION_LOADING']['dialog'].close() + self.load_window() + + def item_load_finished(self, items): + self.windows['RESULTS']['data'] = { 'items': items } + self.current_window = 'RESULTS' + self.windows['ITEM_LOADING']['dialog'].close() + self.windows['ITEM_LOADING']['data'] = None + self.windows['ITEM_LOADING']['dialog'] = None + self.load_window() def add_action( self, @@ -76,44 +130,6 @@ def add_action( status_tip=None, whats_this=None, parent=None): - """Add a toolbar icon to the toolbar. - - :param icon_path: Path to the icon for this action. Can be a resource - path (e.g. ':/plugins/foo/bar.png') or a normal file system path. - :type icon_path: str - - :param text: Text that should be shown in menu items for this action. - :type text: str - - :param callback: Function to be called when the action is triggered. - :type callback: function - - :param enabled_flag: A flag indicating if the action should be enabled - by default. Defaults to True. - :type enabled_flag: bool - - :param add_to_menu: Flag indicating whether the action should also - be added to the menu. Defaults to True. - :type add_to_menu: bool - - :param add_to_toolbar: Flag indicating whether the action should also - be added to the toolbar. Defaults to True. - :type add_to_toolbar: bool - - :param status_tip: Optional text to show in a popup when mouse pointer - hovers over the action. - :type status_tip: str - - :param parent: Parent widget for the new action. Defaults None. - :type parent: QWidget - - :param whats_this: Optional text to show in the status bar when the - mouse pointer hovers over the action. - - :returns: The action that was created. Note that the action is also - added to self.actions list. - :rtype: QAction - """ icon = QIcon(icon_path) action = QAction(icon, text, parent) @@ -140,53 +156,17 @@ def add_action( return action def initGui(self): - """Create the menu entries and toolbar icons inside the QGIS GUI.""" icon_path = ':/plugins/stac_browser/assets/icon.png' self.add_action( icon_path, - text=self.tr(u'Browse STAC Catalogs'), - callback=self.run, + text='Browse STAC Catalogs', + callback=self.load_window, parent=self.iface.mainWindow()) - # will be set False in run() - self.first_start = True - - def unload(self): """Removes the plugin menu item and icon from QGIS GUI.""" for action in self.actions: self.iface.removePluginWebMenu( - self.tr(u'&STAC Browser'), + u'&STAC Browser', action) self.iface.removeToolBarIcon(action) - - - def run(self): - # Create the dialog with elements (after translation) and keep reference - # Only create GUI ONCE in callback, so that it will only load when the plugin is started - if self.first_start == True: - self.first_start = False - self.dlg = CatalogLoadingDialog(on_finished=self.on_catalog_loading_finished) - - # show the dialog - self.dlg.show() - result = self.dlg.exec_() - - def on_catalog_loading_finished(self, results): - self.dlg.close() - self.dlg = QueryDialog() - self.dlg.show() - - result = self.dlg.exec_() - if result: - self.on_search() - - def on_search(self): - self.dlg.close() - self.dlg = ResultsDialog() - self.dlg.show() - - result = self.dlg.exec_() - if result: - self.dlg.close() - self.first_start = True diff --git a/ui/catalog_loading_dialog.py b/ui/catalog_loading_dialog.py deleted file mode 100644 index f45ca98..0000000 --- a/ui/catalog_loading_dialog.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- - -import os -import threading -import time -import queue - -from PyQt5.QtCore import QThread, pyqtSignal -from PyQt5 import uic -from PyQt5 import QtWidgets - -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'catalog_loading_dialog.ui')) - - -class CatalogLoadingDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, parent=None, on_finished=None): - """Constructor.""" - super(CatalogLoadingDialog, self).__init__(parent) - - self.on_finished = on_finished - self.setupUi(self) - - self.setFixedSize(self.size()) - - self.catalogs = ['a', 'b', 'c', 'd', 'e', 'f'] - - self.loading_thread = LoadCatalogsThread(self.catalogs, - on_progress=self.on_progress_update, - on_finished=self.on_loading_finished) - - self.loading_thread.start() - - def on_progress_update(self, progress): - self.progressBar.setValue(int(progress*100)) - - def on_loading_finished(self): - self.progressBar.setValue(100) - - if self.on_finished is None: - return - - self.on_finished(None) - -class LoadCatalogsThread(QThread): - progress_signal = pyqtSignal(float) - finished_signal = pyqtSignal() - - def __init__(self, catalogs, on_progress=None, on_finished=None): - QThread.__init__(self) - self.catalogs = catalogs - self.on_progress=on_progress - self.on_finished=on_finished - - self.progress_signal.connect(self.on_progress) - self.finished_signal.connect(self.on_finished) - - def __del__(self): - self.wait() - - def run(self): - for i, k in enumerate(self.catalogs): - percent_finished = float(i) / float(len(self.catalogs)) - self.progress_signal.emit(percent_finished) - time.sleep(0.25) - - self.finished_signal.emit() diff --git a/ui/collection_loading_dialog.py b/ui/collection_loading_dialog.py new file mode 100644 index 0000000..e62efb1 --- /dev/null +++ b/ui/collection_loading_dialog.py @@ -0,0 +1,87 @@ +import os +import threading +import time +import queue +import requests + +from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5 import uic +from PyQt5 import QtWidgets + +from qgis.core import QgsLogger +from ..utils.config import Config +from ..models.catalog import Catalog + +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'collection_loading_dialog.ui')) + + +class CollectionLoadingDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None): + super(CollectionLoadingDialog, self).__init__(parent) + self.data = data + self.hooks = hooks + + self.setupUi(self) + + self.setFixedSize(self.size()) + + + self.loading_thread = LoadCollectionsThread(Config().get_api_list(), + on_progress=self.on_progress_update, + on_finished=self.on_loading_finished) + + self.loading_thread.start() + + def on_progress_update(self, progress): + self.progressBar.setValue(int(progress*100)) + + def on_loading_finished(self, collections): + self.progressBar.setValue(100) + + self.hooks['on_finished'](collections) + + def closeEvent(self, event): + if event.spontaneous(): + self.loading_thread.stop() + self.hooks['on_close']() + +class LoadCollectionsThread(QThread): + progress_signal = pyqtSignal(float) + finished_signal = pyqtSignal(list) + + def __init__(self, api_list, on_progress=None, on_finished=None): + QThread.__init__(self) + self._running = True + self.api_list = api_list + self.on_progress=on_progress + self.on_finished=on_finished + + self.progress_signal.connect(self.on_progress) + self.finished_signal.connect(self.on_finished) + + def __del__(self): + self.wait() + + def run(self): + all_collections = [] + for i, api_url in enumerate(self.api_list): + if not self._running: + return + progress = (float(i) / float(len(self.api_list))/2) + self.progress_signal.emit(progress) + catalog = Catalog(url=api_url) + collections = catalog.get_collections() + all_collections.extend(collections) + + for i, collection in enumerate(all_collections): + if not self._running: + return + progress = (float(i) / float(len(all_collections))/2) + 0.5 + self.progress_signal.emit(progress) + collection.load() + + self.finished_signal.emit(all_collections) + + def stop(self): + self._running = False diff --git a/ui/catalog_loading_dialog.ui b/ui/collection_loading_dialog.ui similarity index 100% rename from ui/catalog_loading_dialog.ui rename to ui/collection_loading_dialog.ui diff --git a/ui/item_loading_dialog.py b/ui/item_loading_dialog.py new file mode 100644 index 0000000..5131614 --- /dev/null +++ b/ui/item_loading_dialog.py @@ -0,0 +1,90 @@ +import os +import threading +import time +import queue +import requests + +from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5 import uic +from PyQt5 import QtWidgets + +from qgis.core import QgsLogger +from ..utils.config import Config +from ..models.catalog import Catalog + +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'item_loading_dialog.ui')) + + +class ItemLoadingDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None): + super(ItemLoadingDialog, self).__init__(parent) + self.data = data + self.hooks = hooks + self.setupUi(self) + + self.setFixedSize(self.size()) + + self.loading_thread = LoadItemsThread(self.data['collections'], + self.data['extent'], + self.data['start_time'], + self.data['end_time'], + on_progress=self.on_progress, + on_finished=self.on_finished) + + self.loading_thread.start() + + def on_progress(self, current_page): + self.loadingLabel.setText(f'Searching Page {current_page}...') + + def on_finished(self, items): + self.hooks['on_finished'](items) + + def closeEvent(self, event): + if event.spontaneous(): + self.loading_thread.stop() + self.hooks['on_close']() + +class LoadItemsThread(QThread): + progress_signal = pyqtSignal(int) + finished_signal = pyqtSignal(list) + + def __init__(self, collections, extent, start_time, end_time, + on_progress=None, on_finished=None): + QThread.__init__(self) + self._running = True + self.current_page = 0 + + self.collections = collections + self.extent = extent + self.start_time = start_time + self.end_time = end_time + self.on_progress=on_progress + self.on_finished=on_finished + + self.progress_signal.connect(self.on_progress) + self.finished_signal.connect(self.on_finished) + + def __del__(self): + self.wait() + + def stop(self): + self._running = False + + def run(self): + all_items = [] + for api_url, collections in self.collections.items(): + if not self._running: + break + catalog = Catalog(url=api_url) + items = catalog.search_items(collections, + self.extent, + self.start_time, + self.end_time, + on_next_page=self.on_next_page) + all_items.extend(items) + self.finished_signal.emit(all_items) + + def on_next_page(self): + self.current_page += 1 + self.progress_signal.emit(self.current_page) diff --git a/ui/item_loading_dialog.ui b/ui/item_loading_dialog.ui new file mode 100644 index 0000000..2e992c4 --- /dev/null +++ b/ui/item_loading_dialog.ui @@ -0,0 +1,37 @@ + + + STACBrowserLoading + + + + 0 + 0 + 177 + 52 + + + + + 0 + 0 + + + + STAC Browser + + + + + + Searching Page 1... + + + Qt::AlignCenter + + + + + + + + diff --git a/ui/preview.jpg b/ui/preview.jpg deleted file mode 100644 index 4ee9d98f99e1f6c15f133b6b9a1ed6457cff10ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 157207 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 diff --git a/ui/query_dialog.py b/ui/query_dialog.py index cc0194c..b66c8d2 100644 --- a/ui/query_dialog.py +++ b/ui/query_dialog.py @@ -1,39 +1,91 @@ -# -*- coding: utf-8 -*- - import os +from datetime import datetime from PyQt5 import uic from PyQt5 import QtWidgets from PyQt5 import QtCore from PyQt5.QtGui import QStandardItemModel, QStandardItem -# This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer +from qgis.core import QgsProject + FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'query_dialog.ui')) class QueryDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, parent=None): - """Constructor.""" + def __init__(self, data={}, hooks={}, parent=None): super(QueryDialog, self).__init__(parent) - # Set up the user interface from Designer through FORM_CLASS. - # After self.setupUi() you can access any designer object by doing - # self., and you can use autoconnect slots - see - # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html - # #widgets-and-dialogs-with-auto-connect + self.data = data + self.hooks = hooks self.setupUi(self) now = QtCore.QDateTime.currentDateTimeUtc() self.endPeriod.setDateTime(now) - self.setFixedSize(self.size()) - model = QStandardItemModel(self.list) - - collections = ["Canada AAFC Annual Crop Inventory", "AHN Netherlands 0.5m DEM, Interpolated", "AHN Netherlands 0.5m DEM, Non-Interpolated", "AHN Netherlands 0.5m DEM, Raw Samples", "ASTER L1T Radiance", "Australian 5M DEM", "DEM-H: Australian SRTM Hydrologically Enforced Digital Elevation Model", "DEM-S: Australian Smoothed Digital Elevation Model", "PML_V2: Coupled Evapotranspiration and Gross Primary Product", "SRTM Digital Elevation Data Version 4", "GPWv4: Gridded Population of the World Version 4, Ancillary Data Grids", "GPWv4: Gridded Population of the World Version 4, Population Count", "GPWv4: Gridded Population of the World Version 4, Population Density", "GPWv4: Gridded Population of the World Version 4, UN-Adjusted Population Count", "GPWv4: Gridded Population of the World Version 4, UN-Adjusted Population Density", "Copernicus CORINE Land Cover", "Sentinel-1 SAR GRD: C-band Synthetic Aperture Radar Ground Range Detected, log scaling", "Sentinel-2 MSI: MultiSpectral Instrument, Level-1C", "Sentinel-2 MSI: MultiSpectral Instrument, Level-2A", "Sentinel-3 OLCI EFR: Ocean and Land Color Instrument Earth Observation Full Resolution", "Sentinel-5P NRTI AER AI: Near Real-Time UV Aerosol Index", "Sentinel-5P NRTI CLOUD: Near Real-Time Cloud Dataset\n", "Sentinel-5P NRTI CO: Near Real-Time Carbon Monoxide Data", "Sentinel-5P NRTI HCHO: Near Real-Time Formaldehyde Data", "Sentinel-5P NRTI NO2: Near Real-Time Nitrogen Dioxide Data", "Sentinel-5P NRTI O3: Near Real Time Ozone Data", "Sentinel-5P NRTI SO2: Near Real-Time Sulphur Dioxide Data", "Sentinel-5P OFFL AER AI: Offline UV Aerosol Index", "Sentinel-5P OFFL CLOUD: Sentinel 5 Precursor Tropospheric (S5P/TROPOMI)\nOffline Cloud Data\n", "Sentinel-5P OFFL CO: Offline Carbon Monoxide Data", "Sentinel-5P OFFL HCHO: Offline Formaldehyde Data", "Sentinel-5P OFFL NO2: Offline Nitrogen Dioxide Data", "Sentinel-5P OFFL O3: Offline Ozone Data\n", "Sentinel-5P OFFL SO2: Offline Sulphur Dioxide Data", "CryoSat-2 Antarctica 1km DEM", "SLGA: Soil and Landscape Grid of Australia (Soil Attributes)", "Global ALOS CHILI (Continuous Heat-Insolation Load Index)", "Global ALOS Landforms", "Global ALOS mTPI (Multi-Scale Topographic Position Index)", "Global ALOS Topographic Diversity", "Global SRTM CHILI (Continuous Heat-Insolation Load Index)", "Global SRTM Landforms", "Global SRTM mTPI (Multi-Scale Topographic Position Index)", "Global SRTM Topographic Diversity", "US NED CHILI (Continuous Heat-Insolation Load Index)", "US NED Landforms", "US Lithology", "US NED mTPI (Multi-Scale Topographic Position Index)", "US NED Physiographic Diversity", "US Physiography", "US NED Topographic Diversity", "CSP gHM: Global Human Modification", "EO-1 Hyperion Hyperspectral Imager", "US EPA Ecoregions (Level III)", "US EPA Ecoregions (Level IV)", "GlobCover: Global Land Cover Map", "FAO GAUL: Global Administrative Unit Layers 2015, Country Boundaries", "FAO GAUL: Global Administrative Unit Layers 2015, First-Level Administrative Units", "FAO GAUL: Global Administrative Unit Layers 2015, Second-Level Administrative Units", "FIRMS: Fire Information for Resource Management System", "GFW (Global Fishing Watch) Daily Fishing Hours", "GFW (Global Fishing Watch) Daily Vessel Hours", "GLCF: Landsat Tree Cover Continuous Fields", "GLCF: Landsat Global Inland Water", "GLIMS: Global Land Ice Measurements from Space - 2016", "GLIMS: Global Land Ice Measurements from Space - 2017", "GLIMS: Global Land Ice Measurements from Space - current", "HYCOM: Hybrid Coordinate Ocean Model, Sea Surface Elevation", "HYCOM: Hybrid Coordinate Ocean Model, Water Temperature and Salinity", "HYCOM: Hybrid Coordinate Ocean Model, Water Velocity", "GRIDMET: University of Idaho Gridded Surface Meteorological Dataset", "MACAv2-METDATA: University of Idaho, Multivariate Adaptive Constructed Analogs Applied to Global Climate Models", "MACAv2-METDATA Monthly Summaries: University of Idaho, Multivariate Adaptive Constructed Analogs Applied to Global Climate Models", "PDSI: University of Idaho Palmer Drought Severity Index", "TerraClimate: Monthly Climate and Climatic Water Balance for Global Terrestrial Surfaces, University of Idaho", "ALOS/AVNIR-2 ORI", "ALOS DSM: Global 30m", "Global PALSAR-2/PALSAR Forest/Non-Forest Map", "Global PALSAR-2/PALSAR Yearly Mosaic", "GSMaP Operational: Global Satellite Mapping of Precipitation", "GSMaP Reanalysis: Global Satellite Mapping of Precipitation", "GHSL: Global Human Settlement Layers, Built-Up Grid 1975-1990-2000-2015 (P2016)", "GHSL: Global Human Settlement Layers, Population Grid 1975-1990-2000-2015 (P2016)", "GHSL: Global Human Settlement Layers, Settlement Grid 1975-1990-2000-2014 (P2016)", "JRC Global Surface Water Mapping Layers, v1.0", "JRC Global Surface Water Metadata, v1.0", "JRC Monthly Water History, v1.0", "JRC Monthly Water Recurrence, v1.0", "JRC Yearly Water Classification History, v1.0", "Landsat Global Land Survey 1975 Mosaic", "Landsat Global Land Survey 1975 Mosaic", "Landsat Global Land Survey 2005, Landsat 5+7 scenes", "Landsat Global Land Survey 2005, Landsat 5 scenes", "Landsat Global Land Survey 2005, Landsat 7 scenes", "USGS Landsat 8 Collection 1 Tier 1 Raw Scenes", "Landsat 8 Collection 1 Tier 1 32-Day BAI Composite", "Landsat 8 Collection 1 Tier 1 32-Day EVI Composite", "Landsat 8 Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 8 Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 8 Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 8 Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 8 Collection 1 Tier 1 32-Day Raw Composite", "Landsat 8 Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 8 Collection 1 Tier 1 8-Day BAI Composite", "Landsat 8 Collection 1 Tier 1 8-Day EVI Composite", "Landsat 8 Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 8 Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 8 Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 8 Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 8 Collection 1 Tier 1 8-Day Raw Composite", "Landsat 8 Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 8 Collection 1 Tier 1 Annual BAI Composite", "Landsat 8 Collection 1 Tier 1 Annual EVI Composite", "Landsat 8 Collection 1 Tier 1 Landsat 8 Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 8 Collection 1 Tier 1 Annual NBRT Composite", "Landsat 8 Collection 1 Tier 1 Annual NDSI Composite", "Landsat 8 Collection 1 Tier 1 Annual NDVI Composite", "Landsat 8 Collection 1 Tier 1 Annual NDWI Composite", "Landsat 8 Collection 1 Tier 1 Annual Raw Composite", "Landsat 8 Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 8 Collection 1 Tier 1 and Real-Time data Raw Scenes", "USGS Landsat 8 Collection 1 Tier 1 and Real-Time data TOA Reflectance", "USGS Landsat 8 Surface Reflectance Tier 1", "USGS Landsat 8 Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 8 Collection 1 Tier 2 Raw Scenes", "USGS Landsat 8 Surface Reflectance Tier 2", "USGS Landsat 8 Collection 1 Tier 2 TOA Reflectance", "USGS Landsat 7 Collection 1 Tier 1 Raw Scenes", "Landsat 7 Collection 1 Tier 1 32-Day BAI Composite", "Landsat 7 Collection 1 Tier 1 32-Day EVI Composite", "Landsat 7 Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 7 Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 7 Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 7 Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 7 Collection 1 Tier 1 32-Day Raw Composite", "Landsat 7 Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 7 Collection 1 Tier 1 8-Day BAI Composite", "Landsat 7 Collection 1 Tier 1 8-Day EVI Composite", "Landsat 7 Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 7 Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 7 Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 7 Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 7 Collection 1 Tier 1 8-Day Raw Composite", "Landsat 7 Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 7 Collection 1 Tier 1 Annual BAI Composite", "Landsat 7 Collection 1 Tier 1 Annual EVI Composite", "Landsat 7 Collection 1 Tier 1 Landsat 7 Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 7 Collection 1 Tier 1 Annual NBRT Composite", "Landsat 7 Collection 1 Tier 1 Annual NDSI Composite", "Landsat 7 Collection 1 Tier 1 Annual NDVI Composite", "Landsat 7 Collection 1 Tier 1 Annual NDWI Composite", "Landsat 7 Collection 1 Tier 1 Annual Raw Composite", "Landsat 7 Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 7 Collection 1 Tier 1 and Real-Time data Raw Scenes", "USGS Landsat 7 Collection 1 Tier 1 and Real-Time data TOA Reflectance", "USGS Landsat 7 Surface Reflectance Tier 1", "USGS Landsat 7 Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 7 Collection 1 Tier 2 Raw Scenes", "USGS Landsat 7 Surface Reflectance Tier 2", "USGS Landsat 7 Collection 1 Tier 2 TOA Reflectance", "Landsat 7 annual TOA percentile composites", "Landsat 7 3-year TOA percentile composites", "Landsat 7 5-year TOA percentile composites", "USGS Landsat 1 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 1 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 2 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 2 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 3 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 3 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 4 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 4 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 5 MSS Collection 1 Tier 1 Raw Scenes", "USGS Landsat 5 MSS Collection 1 Tier 2 Raw Scenes", "USGS Landsat 4 TM Collection 1 Tier 1 Raw Scenes", "Landsat 4 TM Collection 1 Tier 1 32-Day BAI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day EVI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day Raw Composite", "Landsat 4 TM Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day BAI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day EVI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day Raw Composite", "Landsat 4 TM Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 4 TM Collection 1 Tier 1 Annual BAI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual EVI Composite", "Landsat 4 TM Collection 1 Tier 1 Landsat 4 TM Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NBRT Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NDSI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NDVI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual NDWI Composite", "Landsat 4 TM Collection 1 Tier 1 Annual Raw Composite", "Landsat 4 TM Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 4 Surface Reflectance Tier 1", "USGS Landsat 4 TM Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 4 TM Collection 1 Tier 2 Raw Scenes", "USGS Landsat 4 Surface Reflectance Tier 2", "USGS Landsat 4 TM Collection 1 Tier 2 TOA Reflectance", "USGS Landsat 5 TM Collection 1 Tier 1 Raw Scenes", "Landsat 5 TM Collection 1 Tier 1 32-Day BAI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day EVI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NBRT Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NDSI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NDVI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day NDWI Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day Raw Composite", "Landsat 5 TM Collection 1 Tier 1 32-Day TOA Reflectance Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day BAI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day EVI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NBRT Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NDSI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NDVI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day NDWI Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day Raw Composite", "Landsat 5 TM Collection 1 Tier 1 8-Day TOA Reflectance Composite", "Landsat 5 TM Collection 1 Tier 1 Annual BAI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual EVI Composite", "Landsat 5 TM Collection 1 Tier 1 Landsat 5 TM Collection 1 Tier 1 Annual Greenest-Pixel TOA Reflectance Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NBRT Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NDSI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NDVI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual NDWI Composite", "Landsat 5 TM Collection 1 Tier 1 Annual Raw Composite", "Landsat 5 TM Collection 1 Tier 1 Annual TOA Reflectance Composite", "USGS Landsat 5 Surface Reflectance Tier 1", "USGS Landsat 5 TM Collection 1 Tier 1 TOA Reflectance", "USGS Landsat 5 TM Collection 1 Tier 2 Raw Scenes", "USGS Landsat 5 Surface Reflectance Tier 2", "USGS Landsat 5 TM Collection 1 Tier 2 TOA Reflectance", "Global Mangrove Forests Distribution, v1 (2000)", "MCD12Q1.006 MODIS Land Cover Type Yearly Global 500m", "MCD15A3H.006 MODIS Leaf Area Index/FPAR 4-Day Global 500m", "MCD43A1.006 MODIS BRDF-Albedo Model Parameters Daily 500m", "MCD43A2.006 MODIS BRDF-Albedo Quality Daily 500m", "MCD43A3.006 MODIS Albedo Daily 500m", "MCD43A4.006 MODIS Nadir BRDF-Adjusted Reflectance, daily 500m", "MCD64A1.006 MODIS Burned Area Monthly Global 500m", "MOD08_M3.006 Terra Atmosphere Monthly Global Product", "MOD09A1.006 Terra Surface Reflectance 8-Day Global 500m", "MOD09GA.006 Terra Surface Reflectance Daily L2G Global 1km and 500m", "MOD09GQ.006 Terra Surface Reflectance Daily Global 250m", "MOD09Q1.006 Terra Surface Reflectance 8-Day Global 250m", "MOD10A1.006 Terra Snow Cover Daily Global 500m", "MOD11A1.006 Terra Land Surface Temperature and Emissivity Daily Global 1km", "MOD11A2.006 Terra Land Surface Temperature and Emissivity 8-Day Global 1km", "MOD13A1.006 Terra Vegetation Indices 16-Day Global 500m", "MOD13A2.006 Terra Vegetation Indices 16-Day Global 1km", "MOD13Q1.006 Terra Vegetation Indices 16-Day Global 250m", "MOD14A1.006: Terra Thermal Anomalies & Fire Daily Global 1km", "MOD14A2.006: Terra Thermal Anomalies & Fire 8-Day Global 1km", "MOD16A2.006: Terra Net Evapotranspiration 8-Day Global 500m", "MOD17A2H.006: Terra Gross Primary Productivity 8-Day Global 500m", "MOD17A3H.006: Terra Net Primary Production Yearly Global 500m", "MOD44W.006 Terra Land Water Mask Derived from MODIS and SRTM Yearly Global 250m", "MODOCGA.006 Terra Ocean Reflectance Daily Global 1km", "MYD08_M3.006 Aqua Atmosphere Monthly Global Product", "MYD09A1.006 Aqua Surface Reflectance 8-Day Global 500m", "MYD09GA.006 Aqua Surface Reflectance Daily L2G Global 1km and 500m", "MYD09GQ.006 Aqua Surface Reflectance Daily Global 250m", "MYD09Q1.006 Aqua Surface Reflectance 8-Day Global 250m", "MYD10A1.006 Aqua Snow Cover Daily Global 500m", "MYD11A1.006 Aqua Land Surface Temperature and Emissivity Daily Global 1km", "MYD11A2.006 Aqua Land Surface Temperature and Emissivity 8-Day Global 1km", "MYD13A1.006 Aqua Vegetation Indices 16-Day Global 500m", "MYD13A2.006 Aqua Vegetation Indices 16-Day Global 1km", "MYD13Q1.006 Aqua Vegetation Indices 16-Day Global 250m", "MYD14A1.006: Aqua Thermal Anomalies & Fire Daily Global 1km", "MYD14A2.006: Aqua Thermal Anomalies & Fire 8-Day Global 1km", "MYD17A2H.006: Aqua Gross Primary Productivity 8-Day Global 500m", "MYD17A3H.006: Aqua Net Primary Production Yearly Global 500m", "MYDOCGA.006 Aqua Ocean Reflectance Daily Global 1km", "MCD12Q1.051 Land Cover Type Yearly Global 500m", "MCD45A1.051 Burned Area Monthly Global 500m", "MOD44B.051 Terra Vegetation Continuous Fields Yearly Global 250m", "MOD17A3.055: Terra Net Primary Production Yearly Global 1km", "MCD12Q2.005 Land Cover Dynamics Yearly Global 500m", "MODIS Combined 16-Day BAI", "MODIS Combined 16-Day EVI", "MODIS Combined 16-Day NDSI", "MODIS Combined 16-Day NDVI", "MODIS Combined 16-Day NDWI", "MCD43B3.005 Albedo 16-Day Global 1km", "MODIS Terra Daily BAI", "MODIS Terra Daily EVI", "MODIS Terra Daily NDSI", "MODIS Terra Daily NDVI", "MODIS Terra Daily NDWI", "MOD13A1.005 Vegetation Indices 16-Day L3 Global 500m", "MOD44W.005 Land Water Mask Derived from MODIS and SRTM", "MODIS Aqua Daily BAI", "MODIS Aqua Daily EVI", "MODIS Aqua Daily NDSI", "MODIS Aqua Daily NDVI", "MODIS Aqua Daily NDWI", "MYD13A1.005 Vegetation Indices 16-Day L3 Global 500m", "MOD16A2: MODIS Global Terrestrial Evapotranspiration 8-Day Global 1km", "AG100: ASTER Global Emissivity Dataset 100-meter V003", "FLDAS: Famine Early Warning Systems Network (FEWS NET) Land Data\nAssimilation System\n", "GIMMS NDVI from AVHRR Sensors (3rd Generation)", "GLDAS-2: Global Land Data Assimilation System", "GLDAS-2.1: Global Land Data Assimilation System", "Reprocessed GLDAS-2: Global Land Data Assimilation System", "GPM: Global Precipitation Measurement (GPM) v5", "GRACE Monthly Mass Grids - Land", "GRACE Monthly Mass Grids - Global Mascons", "GRACE Monthly Mass Grids - Global Mascon (CRI Filtered)", "GRACE Monthly Mass Grids - Ocean", "GRACE Monthly Mass Grids - Ocean EOFR", "Global Forest Canopy Height, 2005", "NEX-DCP30: NASA Earth Exchange Downscaled Climate Projections", "NEX-DCP30: Ensemble Stats for NASA Earth Exchange Downscaled Climate Projections", "NEX-GDDP: NASA Earth Exchange Global Daily Downscaled Climate Projections", "NLDAS-2: North American Land Data Assimilation System Forcing Fields", "Ocean Color SMI: Standard Mapped Image MODIS Aqua Data", "Ocean Color SMI: Standard Mapped Image MODIS Terra Data", "Ocean Color SMI: Standard Mapped Image SeaWiFS Data", "Daymet V3: Daily Surface Weather and Climatological Summaries", "NASA-USDA SMAP Global Soil Moisture Data", "NASA-USDA Global Soil Moisture Data", "NCEP/NCAR Reanalysis Data, Sea-Level Pressure", "NCEP/NCAR Reanalysis Data, Surface Temperature", "NCEP/NCAR Reanalysis Data, Water vapor", "NOAA CDR: Ocean Near-Surface Atmospheric Properties, Version 2", "NOAA CDR AVHRR AOT: Daily Aerosol Optical Thickness Over Global Oceans, v03", "NOAA CDR AVHRR LAI FAPAR: Leaf Area Index and Fraction of Absorbed Photosynthetically Active Radiation, Version 4", "NOAA CDR AVHRR NDVI: Normalized Difference Vegetation Index, Version 4", "NOAA CDR AVHRR: Surface Reflectance, Version 4", "NOAA CDR GRIDSAT-B1: Geostationary IR Channel Brightness Temperature", "NOAA CDR: Ocean Heat Fluxes, Version 2", "NOAA CDR OISST: Optimum Interpolation Sea Surface Temperature", "NOAA CDR PATMOSX: Cloud Properties, Reflectance, and Brightness Temperatures, Version 5.3", "NOAA AVHRR Pathfinder Version 5.3 Collated Global 4km Sea Surface Temperature", "NOAA CDR WHOI: Sea Surface Temperature, Version 2", "CFSV2: NCEP Climate Forecast System Version 2, 6-Hourly Products", "DMSP OLS: Global Radiance-Calibrated Nighttime Lights Version 4, Defense Meteorological Program Operational Linescan System", "DMSP OLS: Nighttime Lights Time Series Version 4, Defense Meteorological Program Operational Linescan System", "GFS: Global Forecast System 384-Hour Predicted Atmosphere Data", "ETOPO1: Global 1 Arc-Minute Elevation", "RTMA: Real-Time Mesoscale Analysis", "PERSIANN-CDR: Precipitation Estimation from Remotely Sensed Information Using Artificial Neural Networks-Climate Data Record", "VNP09GA: VIIRS Surface Reflectance Daily 500m and 1km", "VNP13A1: VIIRS Vegetation Indices 16-Day 500m", "VIIRS Nighttime Day/Night Band Composites Version 1", "VIIRS Stray Light Corrected Nighttime Day/Night Band Composites Version 1", "Canadian Digital Elevation Model", "PRISM Daily Spatial Climate Dataset AN81d", "PRISM Monthly Spatial Climate Dataset AN81m", "PRISM Long-Term Average Climate Dataset Norm81m", "Greenland Ice & Ocean Mask - Greenland Mapping Project (GIMP)", "2000 Greenland Mosaic - Greenland Ice Mapping Project (GIMP)", "Greenland DEM - Greenland Mapping Project (GIMP)", "MEaSUREs Greenland Ice Velocity: Selected Glacier Site Velocity Maps from Optical Images Version 2", "Oxford MAP EVI: Malaria Atlas Project Gap-Filled Enhanced Vegetation Index", "Oxford MAP: Malaria Atlas Project Fractional International Geosphere-Biosphere Programme Landcover", "Oxford MAP LST: Malaria Atlas Project Gap-Filled Daytime Land Surface Temperature", "Oxford MAP LST: Malaria Atlas Project Gap-Filled Nighttime Land Surface Temperature", "Oxford MAP TCB: Malaria Atlas Project Gap-Filled Tasseled Cap Brightness", "Oxford MAP TCW: Malaria Atlas Project Gap-Filled Tasseled Cap Wetness", "Accessibility to Cities 2015", "Global Friction Surface 2015", "RESOLVE Ecoregions 2017", "Planet SkySat Public Ortho Imagery, Multispectral", "Planet SkySat Public Ortho Imagery, RGB", "TIGER: US Census Blocks", "TIGER: US Census Tracts Demographic - Profile 1", "TIGER: US Census Counties 2016", "TIGER: US Census Roads", "TIGER: US Census States 2016", "TOMS and OMI Merged Ozone Data", "TRMM 3B42: 3-Hourly Precipitation Estimates", "TRMM 3B43: Monthly Precipitation Estimates", "CHIRPS Daily: Climate Hazards Group InfraRed Precipitation with Station Data (version 2.0 final)", "CHIRPS Pentad: Climate Hazards Group InfraRed Precipitation with Station Data (version 2.0 final)", "Hansen Global Forest Change v1.6 (2000-2018)", "ArcticDEM Strips", "ArcticDEM Mosaic", "Landsat Gross Primary Production CONUS", "Landsat Net Primary Production CONUS", "MODIS Gross Primary Production CONUS", "MODIS Net Primary Production CONUS", "Murray Global Intertidal Change Data Mask", "Murray Global Intertidal Change Classification", "Murray Global Intertidal Change QA Pixel Count", "NAIP: National Agriculture Imagery Program", "USDA NASS Cropland Data Layers", "LSIB: Large Scale International Boundary Polygons, Detailed", "LSIB: Large Scale International Boundary Polygons, Simplified", "GFSAD1000: Cropland Extent 1km Crop Dominance, Global Food-Support Analysis Data", "GFSAD1000: Cropland Extent 1km Multi-Study Crop Mask, Global Food-Support Analysis Data", "GMTED2010: Global Multi-resolution Terrain Elevation Data 2010", "GTOPO30: Global 30 Arc-Second Elevation", "Landsat Image Mosaic of Antarctica (LIMA) 16-Bit Pan-sharpened Mosaic", "Landsat Image Mosaic of Antarctica (LIMA) - Processed Landsat Scenes (16 bit)", "USGS National Elevation Dataset 1/3 arc-second", "NLCD: USGS National Land Cover Database", "SRTM Digital Elevation Data 30m", "HUC02: USGS Watershed Boundary Dataset of Regions", "HUC04: USGS Watershed Boundary Dataset of Subregions", "HUC06: USGS Watershed Boundary Dataset of Basins", "HUC08: USGS Watershed Boundary Dataset of Subbasins", "HUC10: USGS Watershed Boundary Dataset of Watersheds", "HUC12: USGS Watershed Boundary Dataset of Subwatersheds", "KBDI: Keetch-Byram Drought Index", "PROBA-V C1 Top Of Canopy Daily Synthesis 100m", "PROBA-V C1 Top Of Canopy Daily Synthesis 333m", "WDPA: World Database on Protected Areas (points)", "WDPA: World Database on Protected Areas (polygons)", "MODIS 1-year NBAR Mosaic", "MODIS 2-year NBAR Mosaic", "MODIS 3-year NBAR Mosaic", "WHRC Pantropical National Level Carbon Stock Dataset", "WorldClim BIO Variables V1", "WorldClim Climatology V1", "FORMA Alerts", "FORMA Raw Output FIRMS", "FORMA Raw Output NDVI", "FORMA alert thresholds", "FORMA Vegetation T-Statistics", "Global Power Plant Database", "WWF HydroSHEDS Hydrologically Conditioned DEM, 3 Arc-Seconds", "WWF HydroSHEDS Drainage Direction, 3 Arc-Seconds", "WWF HydroSHEDS Void-Filled DEM, 3 Arc-Seconds", "WWF HydroSHEDS Flow Accumulation, 15 Arc-Seconds", "WWF HydroSHEDS Hydrologically Conditioned DEM, 15 Arc-Seconds", "WWF HydroSHEDS Drainage Direction, 15 Arc-Seconds", "WWF HydroSHEDS Flow Accumulation, 30 Arc-Seconds", "WWF HydroSHEDS Hydrologically Conditioned DEM, 30 Arc-Seconds", "WWF HydroSHEDS Drainage Direction, 30 Arc-Seconds", "WorldPop Project Population Data: Estimated Residential Population per 100x100m Grid Square"] - for collection in sorted(collections): - item = QStandardItem(collection.replace('\n', ' ')) + layers = QgsProject.instance().mapLayers() + self.eligible_layers = [] + for layer_key, layer in layers.items(): + if layer.type() in [0]: + self.eligible_layers.append(layer) + + for layer in self.eligible_layers: + self.extentLayer.addItem(layer.name()) + + self.list_model = QStandardItemModel(self.list) + + for collection in self.get_collections(): + collection_title = collection.get_title().replace('\n', ' ') + item = QStandardItem(f'{collection_title} [{collection.get_parent().get_title()}]') item.setCheckable(True) - model.appendRow(item) + self.list_model.appendRow(item) + + self.list.setModel(self.list_model) + + self.searchButton.clicked.connect(self.on_search_clicked) + self.cancelButton.clicked.connect(self.on_cancel_clicked) + + def on_search_clicked(self): + selected_collections = self.get_selected_collections() + catalog_collections = {} + + for collection in selected_collections: + if catalog_collections.get(collection.get_parent().get_url(), None) is None: + catalog_collections[collection.get_parent().get_url()] = [] + catalog_collections[collection.get_parent().get_url()].append(collection) + + self.hooks['on_search'](catalog_collections, + self.get_extent_layer(), + self.get_time_period()) + + def on_cancel_clicked(self): + self.hooks['on_close']() + + def get_collections(self): + return sorted(self.data['collections']) + + def get_extent_layer(self): + for i, layer in enumerate(self.eligible_layers): + if i == self.extentLayer.currentIndex(): + return layer + + return None + + def get_time_period(self): + return (datetime.strptime(self.startPeriod.text(), '%Y-%m-%d %H:%MZ'), + datetime.strptime(self.endPeriod.text(), '%Y-%m-%d %H:%MZ')) + + def get_selected_collections(self): + selected_collections = [] + all_collections = self.get_collections() + + for row in range(self.list_model.rowCount()): + item = self.list_model.item(row) + if item.checkState() == QtCore.Qt.Checked: + selected_collections.append(all_collections[row]) + + return selected_collections - self.list.setModel(model) + def closeEvent(self, event): + if event.spontaneous(): + self.hooks['on_close']() diff --git a/ui/query_dialog.ui b/ui/query_dialog.ui index 2a299fa..ced2d5a 100644 --- a/ui/query_dialog.ui +++ b/ui/query_dialog.ui @@ -6,170 +6,126 @@ 0 0 - 730 - 270 + 599 + 197 STAC Browser - - - - 10 - 10 - 441 - 250 - - - - QFrame::NoFrame - - - QFrame::Sunken - - - true - - - - - 0 - 0 - 441 - 250 - - - - - - 0 - 0 - 441 - 250 - - - - 0 - - - - - - - - 460 - 10 - 261 - 241 - - - - - - - Extent Layer - - - - - - - - - Qt::LeftToRight - - - ... - - - - - - - true - - - - - - - - - Time Period - - - - - - - yyyy-MM-dd HH:mmZ - - - - - - - to - - - Qt::AlignCenter - - - - - - - yyyy-MM-dd HH:mmZ - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - + + + + + + + + + + 300 + 16777215 + + + + + + + + + + + + + + Extent Layer + + + + + + + + + + Start Time Period + + + + + + + yyyy-MM-dd HH:mmZ + + + + + + + End Time Period + + + Qt::AlignCenter + + + + + + + yyyy-MM-dd HH:mmZ + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 100 + 16777215 + + + + Cancel + + + + + + + + 100 + 16777215 + + + + Search + + + + + + + + + + - - - button_box - accepted() - STACBrowserDialogBase - accept() - - - 20 - 20 - - - 20 - 20 - - - - - button_box - rejected() - STACBrowserDialogBase - reject() - - - 20 - 20 - - - 20 - 20 - - - - + diff --git a/ui/results_dialog.py b/ui/results_dialog.py index 2b3d1c6..8d689fa 100644 --- a/ui/results_dialog.py +++ b/ui/results_dialog.py @@ -1,93 +1,144 @@ -# -*- coding: utf-8 -*- - import os +import time +from PyQt5.QtCore import QThread, pyqtSignal from PyQt5 import uic from PyQt5 import QtWidgets from PyQt5 import QtCore from PyQt5 import QtGui -# This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer +import requests +import shutil + +from ..models.item import Item + FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'results_dialog.ui')) class ResultsDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, parent=None): - """Constructor.""" + def __init__(self, data={}, hooks={}, parent=None): super(ResultsDialog, self).__init__(parent) - # Set up the user interface from Designer through FORM_CLASS. - # After self.setupUi() you can access any designer object by doing - # self., and you can use autoconnect slots - see - # http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html - # #widgets-and-dialogs-with-auto-connect + self.data = data + self.hooks = hooks self.setupUi(self) - - self.setFixedSize(self.size()) - + + self.selected_item = None + + # Populate Item List model = QtGui.QStandardItemModel(self.list) - preview_path = os.path.join(os.path.dirname(__file__), 'preview.jpg') - self.items = { - 'S2B_9VXK_20171013_0': { - 'thumbnail': preview_path, - 'properties': { - 'collection': 'sentinel-2-l1c', - 'datetime': '2017-10-13T20:03:46.461000+00:00', - 'eo:platform': 'sentinel-2b', - 'eo:cloud_cover': 41.52, - 'sentinel:utm_zone': 9, - 'sentinel:latitude_band': 'V', - 'sentinel:grid_square': 'XK', - 'sentinel:sequence': '0', - 'sentinel:product_id': 'S2B_MSIL1C_20171013T200349_N0205_R128_T09VXK_20171013T200346' - } - }, - 'S2B_9VXK_20171014_0': { - 'thumbnail': None, - 'properties': { - - } - }, - 'S2B_9VXK_20171015_0': { - 'thumbnail': preview_path, - 'properties': { - - } - }, - } - - for collection in sorted(list(self.items.keys())): - item = QtGui.QStandardItem(collection.replace('\n', ' ')) - item.setCheckable(True) - model.appendRow(item) + + for item in self.get_items(): + i = QtGui.QStandardItem(item.get_id()) + i.setCheckable(True) + model.appendRow(i) + self.list_model = model self.list.setModel(model) - self.list.clicked.connect(self.on_list_clicked) + # Connect Buttons + self.list.activated.connect(self.on_list_clicked) + self.selectButton.clicked.connect(self.on_select_all_clicked) + self.deselectButton.clicked.connect(self.on_deselect_all_clicked) + self.downloadButton.clicked.connect(self.on_download_clicked) + self.backButton.clicked.connect(self.on_back_clicked) + + def get_items(self): + return sorted(self.data['items']) + + def on_download_clicked(self): + for i in range(self.list_model.rowCount()): + item = self.list_model.item(i) + if item.checkState() != QtCore.Qt.Checked: + continue + + self.get_items()[i].download() + + def on_select_all_clicked(self): + for i in range(self.list_model.rowCount()): + item = self.list_model.item(i) + item.setCheckState(QtCore.Qt.Checked) + + def on_deselect_all_clicked(self): + for i in range(self.list_model.rowCount()): + item = self.list_model.item(i) + item.setCheckState(QtCore.Qt.Unchecked) @QtCore.pyqtSlot(QtCore.QModelIndex) def on_list_clicked(self, index): items = self.list.selectedIndexes() - item_id = sorted(list(self.items.keys()))[int(items[0].row())] - self.load_item(item_id) - - def load_item(self, item_id): - data = self.items[item_id] - if data['thumbnail'] is not None: - image_profile = QtGui.QImage(data['thumbnail']) - image_profile = image_profile.scaled(250, 250, - aspectRatioMode=QtCore.Qt.KeepAspectRatio, - transformMode=QtCore.Qt.SmoothTransformation) - self.imageView.setPixmap(QtGui.QPixmap.fromImage(image_profile)) - else: - self.imageView.setText('No Preview Available') + item = self.get_items()[items[0].row()] + self.load_item(item) - property_keys = sorted(list(data['properties'].keys())) + def load_item(self, item): + self.selected_item = item + self.set_preview(item) + + property_keys = sorted(list(item.get_properties().keys())) self.propertiesTable.setColumnCount(2) self.propertiesTable.setRowCount(len(property_keys)) for i, key in enumerate(property_keys): self.propertiesTable.setItem(i, 0, QtWidgets.QTableWidgetItem(key)) - self.propertiesTable.setItem(i, 1, QtWidgets.QTableWidgetItem(str(data['properties'][key]))) + self.propertiesTable.setItem(i, 1, QtWidgets.QTableWidgetItem(str(item.get_properties()[key]))) self.propertiesTable.resizeColumnsToContents() + + def on_image_loaded(self, item): + if self.selected_item != item: + return + + self.set_preview(item) + + def set_preview(self, item): + if item.get_thumbnail_url() is None: + self.imageView.setText('No Preview Available') + return + + if not os.path.exists(item.get_thumbnail_path()): + self.imageView.setText('Loading Preview...') + self.loading_thread = LoadPreviewThread(item, on_image_loaded=self.on_image_loaded) + self.loading_thread.start() + return + + image_profile = QtGui.QImage(item.get_thumbnail_path()) + image_profile = image_profile.scaled(self.imageView.size().width(), + self.imageView.size().height(), + aspectRatioMode=QtCore.Qt.KeepAspectRatio, + transformMode=QtCore.Qt.SmoothTransformation) + self.imageView.setPixmap(QtGui.QPixmap.fromImage(image_profile)) + + def resizeEvent(self, event): + if self.selected_item is None: + return + + self.set_preview(self.selected_item) + + def closeEvent(self, event): + if event.spontaneous(): + self.hooks['on_close']() + + def on_back_clicked(self): + self.hooks['on_back']() + + +class LoadPreviewThread(QThread): + finished_signal = pyqtSignal(Item) + + def __init__(self, item, on_image_loaded=None): + QThread.__init__(self) + self.item = item + self.on_image_loaded=on_image_loaded + + self.finished_signal.connect(self.on_image_loaded) + + def __del__(self): + self.wait() + + def run(self): + r = requests.get(self.item.get_thumbnail_url(), stream=True) + if r.status_code == 200: + with open(self.item.get_thumbnail_path(), 'wb') as f: + r.raw.decode_content = True + shutil.copyfileobj(r.raw, f) + self.finished_signal.emit(self.item) diff --git a/ui/results_dialog.ui b/ui/results_dialog.ui index 55452e4..ff96ff7 100644 --- a/ui/results_dialog.ui +++ b/ui/results_dialog.ui @@ -6,142 +6,170 @@ 0 0 - 730 - 529 + 776 + 546 STAC Browser - - - - 10 - 10 - 241 - 471 - - - - QFrame::NoFrame - - - QFrame::Sunken - - - true - - - - - 0 - 0 - 241 - 471 - - - - - - 0 - 0 - 240 - 470 - + + + + + QLayout::SetNoConstraint - - 0 - - - - - - - - 290 - 270 - 430 - 210 - - - - QAbstractScrollArea::AdjustToContents - - - false - - - false - - - - - - 10 - 490 - 111 - 32 - - - - Select All - - - - - - 136 - 490 - 111 - 32 - - - - Deselect All - - - - - - 380 - 10 - 250 - 250 - - - - Click an item on the left to view details - - - Qt::AlignCenter - - - - - - 600 - 490 - 113 - 32 - - - - Download - - - - - - 490 - 494 - 111 - 20 - - - - Add To Layers - - + + + + + + + 250 + 16777215 + + + + true + + + + + + + QLayout::SetMaximumSize + + + + + + 125 + 16777215 + + + + Select All + + + + + + + + 125 + 16777215 + + + + Deselect All + + + + + + + + + + + + + + 250 + 250 + + + + Click an item on the left to view details + + + Qt::AlignCenter + + + + + + + + 16777215 + 300 + + + + false + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 125 + 30 + + + + Add to Layers + + + + + + + + 100 + 30 + + + + Back + + + + + + + + 120 + 0 + + + + + 150 + 30 + + + + Download Selected + + + + + + + + + + diff --git a/utils/config.py b/utils/config.py index ae7e399..ec2246e 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,5 +1,5 @@ class Config: - STAC_APIS = ['https://sat-api.developmentseed.org/stac'] + STAC_APIS = ['https://stac.boundlessgeo.io', 'https://sat-api.developmentseed.org'] def __init__(self): pass From fa4f88d10cd67898b0ccbb8377d9be71c728f965 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Sat, 8 Jun 2019 16:40:05 -0500 Subject: [PATCH 07/37] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4eb5ace..eba1473 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To deploy the application to your QGIS plugins directory run `pb_tool` deploy an It's recommended to use the Plugin Reloader plugin within QGIS to easily reload the plugin during development. ## Current version and branches -The [master branch](https://github.com/kbgg/qgis-stac-browser/tree/master) is the 'stable' version of the spec. It is currently version +The [master branch](https://github.com/kbgg/qgis-stac-browser/tree/master) is the 'stable' version of the plugin. It is currently version **0.1** of the plugin. The [dev](https://github.com/kbgg/qgis-stac-browser/tree/dev) branch is where active development takes place. Whenever dev stabilizes a release is cut and we merge dev in to master. So master should be stable at any given time. From ae9d36381d9e35b8eb3f5e2cb9b295b2575a71c7 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Sat, 15 Jun 2019 18:43:02 -0500 Subject: [PATCH 08/37] initial downloading --- models/api.py | 72 +++++++++++++ models/catalog.py | 99 ++++++++--------- models/collection.py | 115 +++++++++++++++----- models/item.py | 182 +++++++++++++++++++++++++------- models/link.py | 19 ++++ models/search_result.py | 31 ++++++ stac_browser.py | 100 ++++++++++-------- ui/collection_loading_dialog.py | 38 +++---- ui/collection_loading_dialog.ui | 46 ++++---- ui/downloading_dialog.py | 133 +++++++++++++++++++++++ ui/downloading_dialog.ui | 41 +++++++ ui/item_loading_dialog.py | 38 ++++--- ui/item_loading_dialog.ui | 7 +- ui/query_dialog.py | 135 +++++++++++++---------- ui/query_dialog.ui | 16 +-- ui/results_dialog.py | 117 +++++++++++--------- ui/results_dialog.ui | 39 ++++++- ui/select_bands_dialog.py | 95 +++++++++++++++++ ui/select_bands_dialog.ui | 80 ++++++++++++++ 19 files changed, 1056 insertions(+), 347 deletions(-) create mode 100644 models/api.py create mode 100644 models/link.py create mode 100644 models/search_result.py create mode 100644 ui/downloading_dialog.py create mode 100644 ui/downloading_dialog.ui create mode 100644 ui/select_bands_dialog.py create mode 100644 ui/select_bands_dialog.ui diff --git a/models/api.py b/models/api.py new file mode 100644 index 0000000..efd60ae --- /dev/null +++ b/models/api.py @@ -0,0 +1,72 @@ +import requests +import re +from urllib.parse import urlparse +from .collection import Collection +from .catalog import Catalog +from .search_result import SearchResult + +class API: + VERIFY_SSL = False + + def __init__(self, href=None): + self._href = href + self._catalog = None + + def load_catalog(self): + r = requests.get(f'{self.href}/stac', verify=self.VERIFY_SSL) + return Catalog(self, r.json()) + + def load_collection(self, catalog, collection_id): + r = requests.get(f'{self.href}/collections/{collection_id}', verify=self.VERIFY_SSL) + return Collection(catalog, r.json()) + + def search_items(self, collections=[], bbox=[], start_time=None, + end_time=None, page=1, limit=50, on_next_page=None): + if on_next_page is not None: + on_next_page() + + if end_time is None: + time = start_time.strftime('%Y-%m-%dT%H:%M:%SZ') + else: + time = f'{start_time.strftime("%Y-%m-%dT%H:%M:%SZ")}/{end_time.strftime("%Y-%m-%dT%H:%M:%SZ")}' + + r = requests.post(f'{self.href}/stac/search', + json = { + 'collections': [c.id for c in collections], + 'bbox': bbox, + 'time': time, + 'page': page, + 'limit': limit + }, verify=self.VERIFY_SSL) + + + search_result = SearchResult(self, r.json()) + + items = search_result.items + if len(items) >= limit: + items.extend(self.search_items(collections, bbox, start_time, end_time, page+1, limit, on_next_page=on_next_page)) + + return items + + def collection_id_from_href(self, href): + p = re.compile('\/collections\/(.*)') + m = p.match(urlparse(href).path) + if m is None: + return None + + if m.groups() is None: + return None + + return m.groups()[0] + + @property + def href(self): + return self._href + + @property + def catalog(self): + if self._catalog is None: + self._catalog = self.load_catalog() + + return self._catalog + diff --git a/models/catalog.py b/models/catalog.py index ad2ac52..5ba4183 100644 --- a/models/catalog.py +++ b/models/catalog.py @@ -1,67 +1,54 @@ -import requests -import json -import math -from .collection import Collection -from .item import Item +from .link import Link class Catalog: - def __init__(self, url=None): - self.url = url - self.data = None + def __init__(self, api=None, json={}): + self._api = api + self._json = json + self._collections = None - def get_data(self): - if self.data is None: - return {} - - return self.data + @property + def api(self): + return self._api - def get_title(self): - return self.get_data().get('title', 'Unknown') + @property + def id(self): + return self._json.get('id', None) - def get_url(self): - return self.url + @property + def stac_version(self): + return self._json.get('stac_version', None) - def get_collections(self): - r = requests.get(f'{self.get_url()}/stac', verify=False) - self.data = r.json() - links = r.json().get('links', []) - collections = [] + @property + def title(self): + return self._json.get('title', None) - for link in links: - if link.get('rel', None) == 'child': - collection = Collection(parent=self, url=link.get('href', None)) - collections.append(collection) + @property + def description(self): + return self._json.get('description', None) - return collections + @property + def collections(self): + if self._collections is None: + self.load_collections() - def search_items(self, collections, extent, start_time, end_time, page=0, on_next_page=None): - collection_ids = [] - items = [] - for collection in collections: - collection_ids.append(collection.get_id()) - if on_next_page is not None: - on_next_page() - r = requests.post(f'{self.get_url()}/stac/search', - json={ - 'collections': collection_ids, - 'bbox': extent, - 'page': page, - 'limit': 100, - 'time': f'{start_time.strftime("%Y-%m-%dT%H:%M:%SZ")}/{end_time.strftime("%Y-%m-%dT%H:%M:%SZ")}' - }, verify=False) - data = r.json() - for feature in data['features']: - items.append(Item(data=feature)) + return self._collections - search_meta = data['meta'] - max_page = math.ceil(search_meta['found'] / search_meta['limit'])-1 - if page < max_page: - more_items = self.search_items(collections, - extent, - start_time, - end_time, - page+1, - on_next_page=on_next_page) - items.extend(more_items) + @property + def links(self): + return [Link(l) for l in self._json.get('links', [])] + + @property + def api(self): + return self._api + + def load_collections(self): + self._collections = [] + for link in self.links: + collection_id = self.api.collection_id_from_href(link.href) + if collection_id is None: + continue + self._collections.append(self.api.load_collection(self, collection_id)) + + def __lt__(self, other): + return self.title.lower() < other.title.lower() - return items diff --git a/models/collection.py b/models/collection.py index 9d0ea3b..166a242 100644 --- a/models/collection.py +++ b/models/collection.py @@ -1,38 +1,101 @@ -import requests -import urllib.parse as urlparse -import math -from .item import Item +from .link import Link class Collection: - def __init__(self, parent=None, url=None): - self.parent = parent - self.url = url - self.data = None + def __init__(self, catalog=None, json={}): + self._catalog = catalog + self._json = json - def get_url(self): - return self.url + @property + def stac_version(self): + return self._json.get('stac_version', None) - def get_parent(self): - return self.parent + @property + def id(self): + return self._json.get('id', None) - def get_search_url(self): - return f'{self.get_url()}/items' + @property + def title(self): + return self._json.get('title', None) - def load(self): - r = requests.get(self.get_url(), verify=False) - self.data = r.json() + @property + def description(self): + return self._json.get('description', None) - def get_data(self): - if self.data is None: - return {} + @property + def keywords(self): + return self._json.get('keywords', []) - return self.data + @property + def version(self): + return self._json.get('version', None) - def get_title(self): - return self.get_data().get('title', 'Unknown') + @property + def license(self): + return self._json.get('license', None) - def get_id(self): - return self.get_data().get('id', 'N/A') + @property + def providers(self): + return [Provider(p) for p in self._json.get('providers', [])] + + @property + def extent(self): + return Extent(self._json.get('extent', {})) + + @property + def properties(self): + return self._json.get('properties', []) + + @property + def links(self): + return [Link(l) for l in self._json.get('links', [])] + + @property + def bands(self): + bands = {} + for i, band in enumerate(self.properties.get('eo:bands', [])): + band['band'] = i+1 + bands[band.get('name', None)] = band + + return bands + + @property + def catalog(self): + return self._catalog def __lt__(self, other): - return self.get_title() < other.get_title() + return self.title.lower() < other.title.lower() + + +class Extent: + def __init__(self, json={}): + self._json = json + + @property + def spatial(self): + return self._json.get('spatial', []) + + @property + def temporal(self): + return self._json.get('temporal', None) + + +class Provider: + def __init__(self, json={}): + self._json = json + + @property + def name(self): + return self._json.get('name', None) + + @property + def description(self): + return self._json.get('description', None) + + @property + def roles(self): + return self._json.get('roles', []) + + @property + def url(self): + return self._json.get('url', None) + diff --git a/models/item.py b/models/item.py index f0214be..4ab3d0e 100644 --- a/models/item.py +++ b/models/item.py @@ -1,61 +1,163 @@ import os +import subprocess +import requests +import hashlib +import tempfile from pathlib import Path class Item: - def __init__(self, data=None): - self.data = data + def __init__(self, catalog=None, json={}): + self._catalog = catalog + self._json = json - def get_data(self): - if self.data is None: - return {} + @property + def hashed_id(self): + return hashlib.sha256(f'{self.catalog.api.href}/collections/{self.collection.id}/items/{self.id}'.encode('utf-8')).hexdigest() - return self.data + @property + def catalog(self): + return self._catalog - def get_id(self): - return self.get_data().get('id', 'N/A') + @property + def id(self): + return self._json.get('id', None) - def get_properties(self): - return self.get_data().get('properties', {}) + @property + def type(self): + return self._json.get('type', None) - def get_thumbnail_url(self): - thumbnail = self.get_data().get('assets', {}).get('thumbnail', None) - if thumbnail is None: - return None - return thumbnail.get('href', None) + @property + def geometry(self): + return self._json.get('geometry', None) - def get_temp_dir(self): - return os.path.join(Path(__file__).parent.parent, 'tmp') + @property + def bbox(self): + return self._json.get('bbox', None) - def thumbnail_downloaded(self): - return os.path.exists(self.get_thumbnail_path()) + @property + def properties(self): + return self._json.get('properties', {}) + + @property + def links(self): + return [Link(l) for l in self._json.get('links', [])] + + @property + def assets(self): + assets = {} + for key, d in self._json.get('assets', {}).items(): + assets[key] = Asset(d) + + return assets + + @property + def collection(self): + collection_id = self.properties.get('collection', None) + if collection_id is None: + collection_id = self._json.get('collection', None) + + for collection in self.catalog.collections: + if collection.id == collection_id: + return collection + + return None + + @property + def thumbnail(self): + return self.assets.get('thumbnail', None) - def get_thumbnail_path(self): - if not os.path.exists(self.get_temp_dir()): - os.makedirs(self.get_temp_dir()) - previews_dir = os.path.join(self.get_temp_dir(), 'previews') + @property + def thumbnail_url(self): + if self.thumbnail is None: + return None + + return self.thumbnail.href - if not os.path.exists(previews_dir): - os.makedirs(previews_dir) - path = os.path.join(previews_dir, self.get_id()) + @property + def temp_dir(self): + temp_dir = os.path.join(tempfile.gettempdir(), 'qgis-stac-browser', self.hashed_id) + if not os.path.exists(temp_dir): + os.makedirs(temp_dir) - return path + return temp_dir - def asset_downloaded(self): - return os.path.exists(self.get_asset_path()) + @property + def thumbnail_path(self): + return os.path.join(self.temp_dir, 'thumbnail.jpg') - def get_asset_path(self): - if not os.path.exists(self.get_temp_dir()): - os.makedirs(self.get_temp_dir()) - assets_dir = os.path.join(self.get_temp_dir(), 'assets') + @property + def vrt(self): + return os.path.join(self.temp_dir, 'asset.vrt') - if not os.path.exists(assets_dir): - os.makedirs(assets_dir) - path = os.path.join(assets_dir, self.get_id()) + @property + def bands(self): + bands = [] + for k, band in self.collection.bands.items(): + asset = self.assets.get(k, None) + if asset is not None: + bands.append(asset) - return path + return bands - def download(self): - pass + def thumbnail_downloaded(self): + return self._thumbnail is not None + + def download(self, bands, download_directory, stream=False, on_update=None): + item_download_directory = os.path.join(download_directory, self.id) + if not os.path.exists(item_download_directory): + os.makedirs(item_download_directory) + + band_filenames = [] + for band in bands: + asset = self.assets.get(band, None) + + if asset is None: + print('!!! ASSET NOT FOUND !!!') + continue + + if stream and asset.cog is not None: + band_filenames.append(asset.cog) + continue + + if on_update is not None: + on_update('DOWNLOADING_BAND', data={'band': band}) + + r = requests.get(asset.href) + temp_filename = os.path.join(item_download_directory, asset.href.split('/')[-1]) + band_filenames.append(temp_filename) + with open(temp_filename, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + if on_update is not None: + on_update('BUILDING_VRT') + arguments = ['gdalbuildvrt', '-separate', os.path.join(download_directory, f'{self.id}.vrt')] + arguments.extend(band_filenames) + subprocess.run(arguments) def __lt__(self, other): - return self.get_id() < other.get_id() + return self.id < other.id + +class Asset: + def __init__(self, json={}): + self._json = json + + @property + def cog(self): + if self.type in ['image/x.geotiff', 'image/vnd.stac.geotiff']: + return f'/vsicurl/{self.href}' + + return None + + @property + def href(self): + return self._json.get('href', None) + + @property + def title(self): + return self._json.get('title', None) + + @property + def type(self): + return self._json.get('type', None) diff --git a/models/link.py b/models/link.py new file mode 100644 index 0000000..06cf477 --- /dev/null +++ b/models/link.py @@ -0,0 +1,19 @@ +class Link: + def __init__(self, json={}): + self._json = json + + @property + def href(self): + return self._json.get('href', None) + + @property + def rel(self): + return self._json.get('rel', None) + + @property + def type(self): + return self._json.get('type', None) + + @property + def title(self): + return self._json.get('title', None) diff --git a/models/search_result.py b/models/search_result.py new file mode 100644 index 0000000..eb43ab1 --- /dev/null +++ b/models/search_result.py @@ -0,0 +1,31 @@ +from .item import Item +from .link import Link + +class SearchResult: + def __init__(self, api=None, json={}): + self._api = api + self._json = json + + @property + def api(self): + return self._api + + @property + def catalog(self): + return self._api.catalog + + @property + def type(self): + return self._json.get('type', None) + + @property + def meta(self): + return self._json.get('meta', None) + + @property + def items(self): + return [Item(self.catalog, f) for f in self._json.get('features', [])] + + @property + def links(self): + return [Link(l) for l in self._json.get('links', [])] diff --git a/stac_browser.py b/stac_browser.py index 17d69cd..a38dfea 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -1,12 +1,14 @@ from PyQt5.QtCore import QSettings, QCoreApplication from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QAction +from PyQt5.QtWidgets import QAction, QApplication from .resources import * from .ui.collection_loading_dialog import CollectionLoadingDialog from .ui.query_dialog import QueryDialog from .ui.item_loading_dialog import ItemLoadingDialog from .ui.results_dialog import ResultsDialog +from .ui.downloading_dialog import DownloadingDialog +from .ui.select_bands_dialog import SelectBandsDialog import os.path class STACBrowser: @@ -15,44 +17,52 @@ def __init__(self, iface): self.plugin_dir = os.path.dirname(__file__) self.actions = [] + self.application = None self.menu = u'&STAC Browser' self.current_window = 'COLLECTION_LOADING' + self.windows = { 'COLLECTION_LOADING': { 'class': CollectionLoadingDialog, - 'hooks': {'on_finished': self.collection_load_finished}, + 'hooks': {'on_finished': self.collection_load_finished, 'on_close': self.on_close}, 'data': None, 'dialog': None }, - 'QUERY': { + 'QUERY': { 'class': QueryDialog, 'hooks': {'on_close': self.on_close, 'on_search': self.on_search}, 'data': None, 'dialog': None }, - 'ITEM_LOADING': { + 'ITEM_LOADING': { 'class': ItemLoadingDialog, 'hooks': {'on_close': self.on_close, 'on_finished': self.item_load_finished}, 'data': None, 'dialog': None }, - 'RESULTS': { + 'RESULTS': { 'class': ResultsDialog, - 'hooks': {'on_close': self.on_close, 'on_back': self.on_back}, + 'hooks': {'on_close': self.on_close, 'on_back': self.on_back, 'on_download': self.on_download, 'select_bands': self.select_bands}, + 'data': None, + 'dialog': None + }, + 'DOWNLOADING': { + 'class': DownloadingDialog, + 'hooks': {'on_close': self.on_close, 'on_finished': self.downloading_finished}, 'data': None, 'dialog': None }, } - def on_search(self, collections, extent_layer, time_period): + def on_search(self, catalog_collections, extent_layer, time_period): (start_time, end_time) = time_period extent_rect = extent_layer.extent() extent = [extent_rect.xMinimum(), extent_rect.yMinimum(), extent_rect.xMaximum(), extent_rect.yMaximum()] - self.windows['ITEM_LOADING']['data'] = { - 'collections': collections, + self.windows['ITEM_LOADING']['data'] = { + 'catalog_collections': catalog_collections, 'extent': extent, 'start_time': start_time, 'end_time': end_time @@ -70,13 +80,23 @@ def on_back(self): self.load_window() def on_close(self): - for key, window in self.windows.items(): - if window['dialog'] is not None: - window['dialog'].close() + if self.windows is None: + return + self.reset_windows() - window['dialog'] = None - window['data'] = None + def on_popup_close(self): + return + + def on_download(self, items, selected_bands, download_directory, stream): + self.windows['DOWNLOADING']['data'] = { 'items': items, 'bands': selected_bands, 'download_directory': download_directory, 'stream': stream } + self.current_window = 'DOWNLOADING' + self.windows['RESULTS']['dialog'].close() + self.load_window() + + def downloading_finished(self): + self.windows['DOWNLOADING']['dialog'].close() self.current_window = 'COLLECTION_LOADING' + self.reset_windows() def load_window(self): window = self.windows.get(self.current_window, None) @@ -86,27 +106,25 @@ def load_window(self): return if window['dialog'] is None: - window['dialog'] = window.get('class')(data=window.get('data'), hooks=window.get('hooks')) + window['dialog'] = window.get('class')(data=window.get('data'), + hooks=window.get('hooks'), + parent=self.iface.mainWindow()) window['dialog'].show() else: window['dialog'].raise_() window['dialog'].show() window['dialog'].activateWindow() - + def reset_windows(self): for key, window in self.windows.items(): + if window['dialog'] is not None: + window['dialog'].close() window['data'] = None window['dialog'] = None + self.current_window = 'COLLECTION_LOADING' - def collection_load_finished(self, collections): - collection_ids = [] - final_collections = [] - for collection in collections: - if f'{collection.get_id()}:{collection.get_parent().get_url()}' in collection_ids: - continue - collection_ids.append(f'{collection.get_id()}:{collection.get_parent().get_url()}') - final_collections.append(collection) - self.windows['QUERY']['data'] = { 'collections': final_collections } + def collection_load_finished(self, apis): + self.windows['QUERY']['data'] = { 'catalogs': [api.catalog for api in apis] } self.current_window = 'QUERY' self.windows['COLLECTION_LOADING']['dialog'].close() self.load_window() @@ -119,18 +137,18 @@ def item_load_finished(self, items): self.windows['ITEM_LOADING']['dialog'] = None self.load_window() - def add_action( - self, - icon_path, - text, - callback, - enabled_flag=True, - add_to_menu=True, - add_to_toolbar=True, - status_tip=None, - whats_this=None, - parent=None): + def select_bands(self, items, download_directory): + select_bands = SelectBandsDialog(data={'items': items}, hooks={'on_close': self.on_close}, parent=self.windows['RESULTS']['dialog']) + result = select_bands.exec_() + + if not result: + return + + self.on_download(items, select_bands.selected_bands, download_directory, select_bands.stream) + def add_action(self, icon_path, text, callback, enabled_flag=True, + add_to_menu=True, add_to_toolbar=True, status_tip=None, + whats_this=None, parent=None): icon = QIcon(icon_path) action = QAction(icon, text, parent) action.triggered.connect(callback) @@ -143,13 +161,10 @@ def add_action( action.setWhatsThis(whats_this) if add_to_toolbar: - # Adds plugin icon to Plugins toolbar self.iface.addToolBarIcon(action) if add_to_menu: - self.iface.addPluginToWebMenu( - self.menu, - action) + self.iface.addPluginToWebMenu(self.menu, action) self.actions.append(action) @@ -164,9 +179,6 @@ def initGui(self): parent=self.iface.mainWindow()) def unload(self): - """Removes the plugin menu item and icon from QGIS GUI.""" for action in self.actions: - self.iface.removePluginWebMenu( - u'&STAC Browser', - action) + self.iface.removePluginWebMenu(u'&STAC Browser', action) self.iface.removeToolBarIcon(action) diff --git a/ui/collection_loading_dialog.py b/ui/collection_loading_dialog.py index e62efb1..9a356c8 100644 --- a/ui/collection_loading_dialog.py +++ b/ui/collection_loading_dialog.py @@ -10,6 +10,7 @@ from qgis.core import QgsLogger from ..utils.config import Config +from ..models.api import API from ..models.catalog import Catalog FORM_CLASS, _ = uic.loadUiType(os.path.join( @@ -33,21 +34,21 @@ def __init__(self, data={}, hooks={}, parent=None): self.loading_thread.start() - def on_progress_update(self, progress): + def on_progress_update(self, progress, api): + self.label.setText(f'Loading {api}') self.progressBar.setValue(int(progress*100)) - def on_loading_finished(self, collections): + def on_loading_finished(self, apis): self.progressBar.setValue(100) - - self.hooks['on_finished'](collections) + self.hooks['on_finished'](apis) def closeEvent(self, event): if event.spontaneous(): - self.loading_thread.stop() + self.loading_thread.terminate() self.hooks['on_close']() class LoadCollectionsThread(QThread): - progress_signal = pyqtSignal(float) + progress_signal = pyqtSignal(float, str) finished_signal = pyqtSignal(list) def __init__(self, api_list, on_progress=None, on_finished=None): @@ -60,28 +61,19 @@ def __init__(self, api_list, on_progress=None, on_finished=None): self.progress_signal.connect(self.on_progress) self.finished_signal.connect(self.on_finished) - def __del__(self): - self.wait() - def run(self): - all_collections = [] + apis = [] for i, api_url in enumerate(self.api_list): if not self._running: return - progress = (float(i) / float(len(self.api_list))/2) - self.progress_signal.emit(progress) - catalog = Catalog(url=api_url) - collections = catalog.get_collections() - all_collections.extend(collections) - - for i, collection in enumerate(all_collections): - if not self._running: - return - progress = (float(i) / float(len(all_collections))/2) + 0.5 - self.progress_signal.emit(progress) - collection.load() + progress = (float(i) / float(len(self.api_list))) + self.progress_signal.emit(progress, api_url) + api = API(api_url) + api.catalog.load_collections() + apis.append(api) - self.finished_signal.emit(all_collections) + self.finished_signal.emit(apis) + self.quit() def stop(self): self._running = False diff --git a/ui/collection_loading_dialog.ui b/ui/collection_loading_dialog.ui index 7414508..15d5242 100644 --- a/ui/collection_loading_dialog.ui +++ b/ui/collection_loading_dialog.ui @@ -6,8 +6,8 @@ 0 0 - 275 - 60 + 475 + 64 @@ -19,32 +19,22 @@ STAC Browser - - - - 10 - 10 - 100 - 16 - - - - Loading... - - - - - - 10 - 30 - 255 - 23 - - - - 0 - - + + + + + Loading... + + + + + + + 0 + + + + diff --git a/ui/downloading_dialog.py b/ui/downloading_dialog.py new file mode 100644 index 0000000..de267f7 --- /dev/null +++ b/ui/downloading_dialog.py @@ -0,0 +1,133 @@ +import os +import threading +import time +import queue +import requests + +from PyQt5.QtCore import QThread, pyqtSignal +from PyQt5 import uic +from PyQt5 import QtWidgets + +from qgis.core import ( + QgsRasterLayer, + QgsProject +) + +from qgis.core import QgsLogger +from ..utils.config import Config +from ..models.catalog import Catalog +from ..models.item import Item + +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'downloading_dialog.ui')) + + +class DownloadingDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None): + super(DownloadingDialog, self).__init__(parent) + self.data = data + self.hooks = hooks + + self.setupUi(self) + + self.setFixedSize(self.size()) + + self.loading_thread = DownloadItemsThread(self.items, + self.bands, + self.download_directory, + self.stream, + on_progress=self.on_progress_update, + on_add_layer=self.on_add_layer, + on_finished=self.on_downloading_finished) + + self.loading_thread.start() + + @property + def items(self): + return self.data.get('items', []) + + @property + def bands(self): + return self.data.get('bands', []) + + @property + def download_directory(self): + return self.data.get('download_directory', None) + + @property + def stream(self): + return self.data.get('stream', False) + + def on_add_layer(self, current_item, total_items, item, download_directory): + self.on_progress_update(current_item, total_items, 'ADDING_TO_LAYERS') + layer = QgsRasterLayer(os.path.join(download_directory, f'{item.id}.vrt'), item.id) + QgsProject.instance().addMapLayer(layer) + + def on_progress_update(self, current_item, total_items, state, data={}): + label_text = f'Item {current_item+1} of {total_items}' + '\n' + if state == 'DOWNLOADING_BAND': + current_band = data.get('band', '???') + label_text += f'Downloading Band {current_band}' + elif state == 'BUILDING_VRT': + label_text += 'Building Virtual Raster' + elif state == 'ADDING_TO_LAYERS': + label_text += 'Adding to Layers' + + self.label.setText(label_text) + + progress = int((current_item / total_items) * 100) + self.progressBar.setValue(progress) + + def on_downloading_finished(self): + self.progressBar.setValue(100) + self.hooks['on_finished']() + + def closeEvent(self, event): + if event.spontaneous(): + self.loading_thread.stop() + self.hooks['on_close']() + +class DownloadItemsThread(QThread): + progress_signal = pyqtSignal(int, int, str, dict) + add_layer_signal = pyqtSignal(int, int, Item, str) + finished_signal = pyqtSignal() + + def __init__(self, items, bands, download_directory, stream, on_progress=None, on_add_layer=None, on_finished=None): + QThread.__init__(self) + self._running = True + self.items = items + self.bands = bands + self.download_directory = download_directory + self.stream = stream + self.on_progress=on_progress + self.on_add_layer = on_add_layer + self.on_finished=on_finished + + self._current_item = None + + self.progress_signal.connect(self.on_progress) + self.add_layer_signal.connect(self.on_add_layer) + self.finished_signal.connect(self.on_finished) + + def __del__(self): + self.wait() + + def run(self): + for i, item in enumerate(self.items): + if not self._running: + return + + self._current_item = i + bands = [] + for collection_band in self.bands: + if collection_band['collection'] == item.collection: + bands = collection_band['bands'] + item.download(bands, self.download_directory, self.stream, on_update=self.on_update) + self.add_layer_signal.emit(i, len(self.items), item, self.download_directory) + self.finished_signal.emit() + + def on_update(self, state, data={}): + self.progress_signal.emit(self._current_item, len(self.items), state, data) + + def stop(self): + self._running = False diff --git a/ui/downloading_dialog.ui b/ui/downloading_dialog.ui new file mode 100644 index 0000000..502a8fd --- /dev/null +++ b/ui/downloading_dialog.ui @@ -0,0 +1,41 @@ + + + STACBrowserLoading + + + + 0 + 0 + 253 + 97 + + + + + 0 + 0 + + + + STAC Browser + + + + + + Loading... + + + + + + + 0 + + + + + + + + diff --git a/ui/item_loading_dialog.py b/ui/item_loading_dialog.py index 5131614..c2a2310 100644 --- a/ui/item_loading_dialog.py +++ b/ui/item_loading_dialog.py @@ -25,7 +25,7 @@ def __init__(self, data={}, hooks={}, parent=None): self.setFixedSize(self.size()) - self.loading_thread = LoadItemsThread(self.data['collections'], + self.loading_thread = LoadItemsThread(self.data['catalog_collections'], self.data['extent'], self.data['start_time'], self.data['end_time'], @@ -34,33 +34,35 @@ def __init__(self, data={}, hooks={}, parent=None): self.loading_thread.start() - def on_progress(self, current_page): - self.loadingLabel.setText(f'Searching Page {current_page}...') + def on_progress(self, collections, current_page): + collection_label = ', '.join([c.title for c in collections]) + self.loadingLabel.setText(f'Searching {collection_label}\nPage {current_page}...') def on_finished(self, items): self.hooks['on_finished'](items) def closeEvent(self, event): if event.spontaneous(): - self.loading_thread.stop() + self.loading_thread.terminate() self.hooks['on_close']() class LoadItemsThread(QThread): - progress_signal = pyqtSignal(int) + progress_signal = pyqtSignal(list, int) finished_signal = pyqtSignal(list) - def __init__(self, collections, extent, start_time, end_time, + def __init__(self, catalog_collections, extent, start_time, end_time, on_progress=None, on_finished=None): QThread.__init__(self) self._running = True self.current_page = 0 - self.collections = collections + self.catalog_collections = catalog_collections self.extent = extent self.start_time = start_time self.end_time = end_time self.on_progress=on_progress self.on_finished=on_finished + self._current_collections = [] self.progress_signal.connect(self.on_progress) self.finished_signal.connect(self.on_finished) @@ -73,18 +75,22 @@ def stop(self): def run(self): all_items = [] - for api_url, collections in self.collections.items(): + for catalog_collection in self.catalog_collections: if not self._running: - break - catalog = Catalog(url=api_url) - items = catalog.search_items(collections, - self.extent, - self.start_time, - self.end_time, - on_next_page=self.on_next_page) + return + + catalog = catalog_collection['catalog'] + collections = catalog_collection['collections'] + self._current_collections = collections + + items = catalog.api.search_items(collections, + self.extent, + self.start_time, + self.end_time, + on_next_page=self.on_next_page) all_items.extend(items) self.finished_signal.emit(all_items) def on_next_page(self): self.current_page += 1 - self.progress_signal.emit(self.current_page) + self.progress_signal.emit(self._current_collections, self.current_page) diff --git a/ui/item_loading_dialog.ui b/ui/item_loading_dialog.ui index 2e992c4..74bd76d 100644 --- a/ui/item_loading_dialog.ui +++ b/ui/item_loading_dialog.ui @@ -6,8 +6,8 @@ 0 0 - 177 - 52 + 345 + 68 @@ -28,6 +28,9 @@ Qt::AlignCenter + + true + diff --git a/ui/query_dialog.py b/ui/query_dialog.py index b66c8d2..5802067 100644 --- a/ui/query_dialog.py +++ b/ui/query_dialog.py @@ -5,8 +5,9 @@ from PyQt5 import QtWidgets from PyQt5 import QtCore from PyQt5.QtGui import QStandardItemModel, QStandardItem +from PyQt5.QtWidgets import QTreeWidgetItem -from qgis.core import QgsProject +from qgis.core import QgsProject, QgsMapLayer FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'query_dialog.ui')) @@ -15,77 +16,101 @@ class QueryDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None): super(QueryDialog, self).__init__(parent) - self.data = data + self.data = data self.hooks = hooks self.setupUi(self) - now = QtCore.QDateTime.currentDateTimeUtc() + self._extent_layers = None + self._catalog_tree_model = None + + self.populate_time_periods() + self.populate_extent_layers() + self.populate_collection_list() + self.searchButton.clicked.connect(self.on_search_clicked) + self.cancelButton.clicked.connect(self.on_cancel_clicked) + + def populate_time_periods(self): + now = QtCore.QDateTime.currentDateTimeUtc() self.endPeriod.setDateTime(now) + def populate_extent_layers(self): + self._extent_layers = [] + layers = QgsProject.instance().mapLayers() - self.eligible_layers = [] for layer_key, layer in layers.items(): - if layer.type() in [0]: - self.eligible_layers.append(layer) + if layer.type() in [QgsMapLayer.VectorLayer]: + self._extent_layers.append(layer) - for layer in self.eligible_layers: + for layer in self._extent_layers: self.extentLayer.addItem(layer.name()) - self.list_model = QStandardItemModel(self.list) - - for collection in self.get_collections(): - collection_title = collection.get_title().replace('\n', ' ') - item = QStandardItem(f'{collection_title} [{collection.get_parent().get_title()}]') - item.setCheckable(True) - self.list_model.appendRow(item) - - self.list.setModel(self.list_model) - - self.searchButton.clicked.connect(self.on_search_clicked) - self.cancelButton.clicked.connect(self.on_cancel_clicked) + def populate_collection_list(self): + self._catalog_tree_model = QStandardItemModel(self.treeView) + for catalog in self.catalogs: + catalog_node = QTreeWidgetItem(self.treeView) + catalog_node.setText(0, f'{catalog.title}') + catalog_node.setFlags(catalog_node.flags() | QtCore.Qt.ItemIsTristate | QtCore.Qt.ItemIsUserCheckable) + for collection in sorted(catalog.collections): + title = collection.title.replace("\n", " ") + collection_node = QTreeWidgetItem(catalog_node) + collection_node.setText(0, title) + collection_node.setFlags(collection_node.flags() | QtCore.Qt.ItemIsUserCheckable) + collection_node.setCheckState(0, QtCore.Qt.Unchecked) + + @property + def catalog_selections(self): + catalog_collections = [] + root = self.treeView.invisibleRootItem() + for i in range(root.childCount()): + catalog_node = root.child(i) + catalog = self.catalogs[i] + selected_collections = [] + for j in range(catalog_node.childCount()): + collection_node = catalog_node.child(j) + collection = self.catalogs[i].collections[j] + if collection_node.checkState(0) == QtCore.Qt.Checked: + selected_collections.append(collection) + + if len(selected_collections) > 0: + catalog_collections.append({ + 'catalog': catalog, + 'collections': selected_collections + }) + return catalog_collections + + @property + def collections(self): + collections = [] + for catalog in sorted(self.data.get('catalogs', [])): + collections.extend(sorted(catalog.collections)) + + return collections + + @property + def catalogs(self): + return sorted(self.data.get('catalogs', [])) + + @property + def extent_layer(self): + if self.extentLayer.currentIndex() >= len(self._extent_layers): + return None + + return self._extent_layers[self.extentLayer.currentIndex()] + + @property + def time_period(self): + return (datetime.strptime(self.startPeriod.text(), '%Y-%m-%d %H:%MZ'), + datetime.strptime(self.endPeriod.text(), '%Y-%m-%d %H:%MZ')) def on_search_clicked(self): - selected_collections = self.get_selected_collections() - catalog_collections = {} - - for collection in selected_collections: - if catalog_collections.get(collection.get_parent().get_url(), None) is None: - catalog_collections[collection.get_parent().get_url()] = [] - catalog_collections[collection.get_parent().get_url()].append(collection) - - self.hooks['on_search'](catalog_collections, - self.get_extent_layer(), - self.get_time_period()) + self.hooks['on_search'](self.catalog_selections, + self.extent_layer, + self.time_period) def on_cancel_clicked(self): self.hooks['on_close']() - def get_collections(self): - return sorted(self.data['collections']) - - def get_extent_layer(self): - for i, layer in enumerate(self.eligible_layers): - if i == self.extentLayer.currentIndex(): - return layer - - return None - - def get_time_period(self): - return (datetime.strptime(self.startPeriod.text(), '%Y-%m-%d %H:%MZ'), - datetime.strptime(self.endPeriod.text(), '%Y-%m-%d %H:%MZ')) - - def get_selected_collections(self): - selected_collections = [] - all_collections = self.get_collections() - - for row in range(self.list_model.rowCount()): - item = self.list_model.item(row) - if item.checkState() == QtCore.Qt.Checked: - selected_collections.append(all_collections[row]) - - return selected_collections - def closeEvent(self, event): if event.spontaneous(): self.hooks['on_close']() diff --git a/ui/query_dialog.ui b/ui/query_dialog.ui index ced2d5a..540bb4e 100644 --- a/ui/query_dialog.ui +++ b/ui/query_dialog.ui @@ -19,13 +19,15 @@ - - - - 300 - 16777215 - - + + + false + + + + 1 + + diff --git a/ui/results_dialog.py b/ui/results_dialog.py index 8d689fa..2cab46f 100644 --- a/ui/results_dialog.py +++ b/ui/results_dialog.py @@ -6,6 +6,7 @@ from PyQt5 import QtWidgets from PyQt5 import QtCore from PyQt5 import QtGui +from PyQt5.QtWidgets import QFileDialog import requests import shutil @@ -22,86 +23,108 @@ def __init__(self, data={}, hooks={}, parent=None): self.data = data self.hooks = hooks self.setupUi(self) - - self.selected_item = None - - # Populate Item List - model = QtGui.QStandardItemModel(self.list) - - for item in self.get_items(): - i = QtGui.QStandardItem(item.get_id()) - i.setCheckable(True) - model.appendRow(i) - self.list_model = model - self.list.setModel(model) + self._item_list_model = None + self._selected_item = None - # Connect Buttons + self.populate_item_list() + self.list.activated.connect(self.on_list_clicked) self.selectButton.clicked.connect(self.on_select_all_clicked) self.deselectButton.clicked.connect(self.on_deselect_all_clicked) self.downloadButton.clicked.connect(self.on_download_clicked) + self.downloadPathButton.clicked.connect(self.on_download_path_clicked) self.backButton.clicked.connect(self.on_back_clicked) - def get_items(self): - return sorted(self.data['items']) + def populate_item_list(self): + self._item_list_model = QtGui.QStandardItemModel(self.list) + + for item in self.items: + i = QtGui.QStandardItem(item.id) + i.setCheckable(True) + self._item_list_model.appendRow(i) + + self.list.setModel(self._item_list_model) + + def populate_item_details(self, item): + property_keys = sorted(list(item.properties.keys())) + + self.propertiesTable.setColumnCount(2) + self.propertiesTable.setRowCount(len(property_keys)) + + for i, key in enumerate(property_keys): + self.propertiesTable.setItem(i, 0, QtWidgets.QTableWidgetItem(key)) + self.propertiesTable.setItem(i, 1, QtWidgets.QTableWidgetItem(str(item.properties[key]))) + self.propertiesTable.resizeColumnsToContents() + + @property + def items(self): + return sorted(self.data.get('items', [])) + + @property + def selected_items(self): + selected_items = [] + for i in range(self._item_list_model.rowCount()): + if self._item_list_model.item(i).checkState() == QtCore.Qt.Checked: + selected_items.append(self.items[i]) + + return selected_items + + @property + def download_directory(self): + return self.downloadDirectory.text() def on_download_clicked(self): - for i in range(self.list_model.rowCount()): - item = self.list_model.item(i) - if item.checkState() != QtCore.Qt.Checked: - continue - - self.get_items()[i].download() + self.hooks['select_bands'](self.selected_items, self.download_directory) + + def on_download_path_clicked(self): + directory = QFileDialog.getExistingDirectory(self, + "Select Download Directory", + "", + QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks) + if directory: + self.downloadDirectory.setText(directory) def on_select_all_clicked(self): - for i in range(self.list_model.rowCount()): - item = self.list_model.item(i) + for i in range(self._item_list_model.rowCount()): + item = self._item_list_model.item(i) item.setCheckState(QtCore.Qt.Checked) def on_deselect_all_clicked(self): - for i in range(self.list_model.rowCount()): - item = self.list_model.item(i) + for i in range(self._item_list_model.rowCount()): + item = self._item_list_model.item(i) item.setCheckState(QtCore.Qt.Unchecked) @QtCore.pyqtSlot(QtCore.QModelIndex) def on_list_clicked(self, index): items = self.list.selectedIndexes() - item = self.get_items()[items[0].row()] - self.load_item(item) + for i in items: + item = self.items[i.row()] + self.select_item(item) - def load_item(self, item): - self.selected_item = item + def select_item(self, item): + self._selected_item = item self.set_preview(item) - - property_keys = sorted(list(item.get_properties().keys())) - - self.propertiesTable.setColumnCount(2) - self.propertiesTable.setRowCount(len(property_keys)) - - for i, key in enumerate(property_keys): - self.propertiesTable.setItem(i, 0, QtWidgets.QTableWidgetItem(key)) - self.propertiesTable.setItem(i, 1, QtWidgets.QTableWidgetItem(str(item.get_properties()[key]))) - self.propertiesTable.resizeColumnsToContents() + self.populate_item_details(item) def on_image_loaded(self, item): - if self.selected_item != item: + if self._selected_item != item: return self.set_preview(item) def set_preview(self, item): - if item.get_thumbnail_url() is None: + if item.thumbnail_url is None: self.imageView.setText('No Preview Available') return - if not os.path.exists(item.get_thumbnail_path()): + if not os.path.exists(item.thumbnail_path): self.imageView.setText('Loading Preview...') self.loading_thread = LoadPreviewThread(item, on_image_loaded=self.on_image_loaded) self.loading_thread.start() return - image_profile = QtGui.QImage(item.get_thumbnail_path()) + image_profile = QtGui.QImage(item.thumbnail_path) image_profile = image_profile.scaled(self.imageView.size().width(), self.imageView.size().height(), aspectRatioMode=QtCore.Qt.KeepAspectRatio, @@ -109,10 +132,10 @@ def set_preview(self, item): self.imageView.setPixmap(QtGui.QPixmap.fromImage(image_profile)) def resizeEvent(self, event): - if self.selected_item is None: + if self._selected_item is None: return - self.set_preview(self.selected_item) + self.set_preview(self._selected_item) def closeEvent(self, event): if event.spontaneous(): @@ -136,9 +159,9 @@ def __del__(self): self.wait() def run(self): - r = requests.get(self.item.get_thumbnail_url(), stream=True) + r = requests.get(self.item.thumbnail_url, stream=True) if r.status_code == 200: - with open(self.item.get_thumbnail_path(), 'wb') as f: + with open(self.item.thumbnail_path, 'wb') as f: r.raw.decode_content = True shutil.copyfileobj(r.raw, f) self.finished_signal.emit(self.item) diff --git a/ui/results_dialog.ui b/ui/results_dialog.ui index ff96ff7..a3a890b 100644 --- a/ui/results_dialog.ui +++ b/ui/results_dialog.ui @@ -103,6 +103,33 @@ + + + + + + Download Path + + + + + + + + + + + 30 + 16777215 + + + + ... + + + + + @@ -120,22 +147,28 @@ + + false + - 125 - 30 + 0 + 0 Add to Layers + + true + - 100 + 130 30 diff --git a/ui/select_bands_dialog.py b/ui/select_bands_dialog.py new file mode 100644 index 0000000..4848653 --- /dev/null +++ b/ui/select_bands_dialog.py @@ -0,0 +1,95 @@ +import os +from datetime import datetime + +from PyQt5 import uic +from PyQt5 import QtWidgets +from PyQt5 import QtCore +from PyQt5.QtGui import QStandardItemModel, QStandardItem + +from qgis.core import QgsProject, QgsMapLayer + +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'select_bands_dialog.ui')) + + +class SelectBandsDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None): + super(SelectBandsDialog, self).__init__(parent) + self.data = data + self.hooks = hooks + self.setupUi(self) + + self._bands_tree_model = None + + self.populate_bands_list() + + self.downloadButton.clicked.connect(self.on_download_clicked) + self.cancelButton.clicked.connect(self.on_cancel_clicked) + + def populate_bands_list(self): + self._bands_tree_model = QStandardItemModel(self.treeView) + for collection in self.collections: + print(collection) + collection_node = QStandardItem(f'{collection.title}') + for band_name, band_data in collection.bands.items(): + if band_data.get('common_name', None) is not None: + band_node = QStandardItem(f'{band_name} ({band_data.get("common_name")})') + else: + band_node = QStandardItem(band_name) + band_node.setCheckable(True) + collection_node.appendRow(band_node) + self._bands_tree_model.appendRow(collection_node) + + self.treeView.setModel(self._bands_tree_model) + self.treeView.expandAll() + + def on_download_clicked(self): + self.accept() + + def on_cancel_clicked(self): + self.reject() + + @property + def items(self): + return self.data.get('items', []) + + @property + def stream(self): + return self.cogCheckbox.checkState() == QtCore.Qt.Checked + + @property + def collections(self): + collections = [] + for item in self.items: + if item.collection not in collections: + collections.append(item.collection) + + return sorted(collections) + + @property + def selected_bands(self): + collection_band_list = [] + for i in range(len(self.collections)): + collection = self.collections[i] + collection_node = self._bands_tree_model.item(i) + bands = list(collection.bands.items()) + selected_bands = [] + for j in range(collection_node.rowCount()): + band_node = collection_node.child(j) + band_name, band_data = bands[j] + + if band_node.checkState() == QtCore.Qt.Checked: + selected_bands.append(band_name) + + if len(selected_bands) > 0: + collection_band_list.append({ + 'collection': collection, + 'bands': selected_bands + }) + + return collection_band_list + + + def closeEvent(self, event): + if event.spontaneous(): + self.reject() diff --git a/ui/select_bands_dialog.ui b/ui/select_bands_dialog.ui new file mode 100644 index 0000000..7f39039 --- /dev/null +++ b/ui/select_bands_dialog.ui @@ -0,0 +1,80 @@ + + + STACBrowserDialogBase + + + + 0 + 0 + 287 + 324 + + + + STAC Browser + + + + + + + + Select Bands to Download + + + + + + + -1 + + + false + + + + + + + Stream Cloud Optimized GeoTIFFs + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + Download + + + + + + + + + + + + From 9bde99f3e6239904167f69b2243e3a895acc14b7 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Sun, 16 Jun 2019 14:57:12 -0500 Subject: [PATCH 09/37] updated downloading dialog --- models/item.py | 4 ++-- ui/downloading_dialog.py | 32 +++++++++++++++++--------------- ui/downloading_dialog.ui | 30 +++++++++++++++++++++++++----- ui/query_dialog.ui | 3 +++ ui/select_bands_dialog.ui | 3 +++ 5 files changed, 50 insertions(+), 22 deletions(-) diff --git a/models/item.py b/models/item.py index 4ab3d0e..6a1b424 100644 --- a/models/item.py +++ b/models/item.py @@ -120,7 +120,7 @@ def download(self, bands, download_directory, stream=False, on_update=None): continue if on_update is not None: - on_update('DOWNLOADING_BAND', data={'band': band}) + on_update('DOWNLOADING_BAND', data={'band': band, 'bands': bands}) r = requests.get(asset.href) temp_filename = os.path.join(item_download_directory, asset.href.split('/')[-1]) @@ -131,7 +131,7 @@ def download(self, bands, download_directory, stream=False, on_update=None): f.write(chunk) if on_update is not None: - on_update('BUILDING_VRT') + on_update('BUILDING_VRT', data={'bands': bands}) arguments = ['gdalbuildvrt', '-separate', os.path.join(download_directory, f'{self.id}.vrt')] arguments.extend(band_filenames) subprocess.run(arguments) diff --git a/ui/downloading_dialog.py b/ui/downloading_dialog.py index de267f7..c4459b2 100644 --- a/ui/downloading_dialog.py +++ b/ui/downloading_dialog.py @@ -64,27 +64,34 @@ def on_add_layer(self, current_item, total_items, item, download_directory): QgsProject.instance().addMapLayer(layer) def on_progress_update(self, current_item, total_items, state, data={}): - label_text = f'Item {current_item+1} of {total_items}' + '\n' + self.totalLabel.setText(f'Item {current_item+1} of {total_items}') + progress = int((current_item / total_items) * 100) + self.totalProgress.setValue(progress) + + total_bands = len(data.get('bands', [])) + total_steps = total_bands + 2 if state == 'DOWNLOADING_BAND': current_band = data.get('band', '???') - label_text += f'Downloading Band {current_band}' + self.itemLabel.setText(f'Downloading Band {current_band}') + current_step = data.get('bands', []).index(current_band) + 1 elif state == 'BUILDING_VRT': - label_text += 'Building Virtual Raster' + self.itemLabel.setText('Building Virtual Raster') + current_step = total_bands + 1 elif state == 'ADDING_TO_LAYERS': - label_text += 'Adding to Layers' - - self.label.setText(label_text) + self.itemLabel.setText('Adding to Layers') + current_step = total_bands + 2 - progress = int((current_item / total_items) * 100) - self.progressBar.setValue(progress) + progress = int(((current_step - 1) / total_steps) * 100) + self.itemProgress.setValue(progress) def on_downloading_finished(self): - self.progressBar.setValue(100) + self.totalProgress.setValue(100) + self.itemProgress.setValue(100) self.hooks['on_finished']() def closeEvent(self, event): if event.spontaneous(): - self.loading_thread.stop() + self.loading_thread.terminate() self.hooks['on_close']() class DownloadItemsThread(QThread): @@ -114,9 +121,6 @@ def __del__(self): def run(self): for i, item in enumerate(self.items): - if not self._running: - return - self._current_item = i bands = [] for collection_band in self.bands: @@ -129,5 +133,3 @@ def run(self): def on_update(self, state, data={}): self.progress_signal.emit(self._current_item, len(self.items), state, data) - def stop(self): - self._running = False diff --git a/ui/downloading_dialog.ui b/ui/downloading_dialog.ui index 502a8fd..0304501 100644 --- a/ui/downloading_dialog.ui +++ b/ui/downloading_dialog.ui @@ -7,7 +7,7 @@ 0 0 253 - 97 + 114 @@ -20,17 +20,37 @@ STAC Browser + + + + 0 + + + + + + + TextLabel + + + Qt::AlignCenter + + + - + Loading... + + Qt::AlignCenter + - - + + - 0 + 24 diff --git a/ui/query_dialog.ui b/ui/query_dialog.ui index 540bb4e..9562951 100644 --- a/ui/query_dialog.ui +++ b/ui/query_dialog.ui @@ -20,6 +20,9 @@ + + true + false diff --git a/ui/select_bands_dialog.ui b/ui/select_bands_dialog.ui index 7f39039..0f88deb 100644 --- a/ui/select_bands_dialog.ui +++ b/ui/select_bands_dialog.ui @@ -25,6 +25,9 @@ + + true + -1 From eb239344c1bafdabbbd380ea51f5ab9ffd85a55a Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Tue, 18 Jun 2019 14:50:45 -0500 Subject: [PATCH 10/37] switched from requests to urllib --- models/api.py | 30 ++++++++++++------------------ models/item.py | 11 ++++------- ui/collection_loading_dialog.py | 1 - ui/downloading_dialog.py | 1 - ui/item_loading_dialog.py | 1 - ui/results_dialog.py | 10 ++-------- ui/select_bands_dialog.py | 1 - utils/network.py | 21 +++++++++++++++++++++ 8 files changed, 39 insertions(+), 37 deletions(-) create mode 100644 utils/network.py diff --git a/models/api.py b/models/api.py index efd60ae..5e7ce86 100644 --- a/models/api.py +++ b/models/api.py @@ -1,24 +1,20 @@ -import requests import re from urllib.parse import urlparse from .collection import Collection from .catalog import Catalog from .search_result import SearchResult +from ..utils import network -class API: - VERIFY_SSL = False - +class API: def __init__(self, href=None): self._href = href self._catalog = None def load_catalog(self): - r = requests.get(f'{self.href}/stac', verify=self.VERIFY_SSL) - return Catalog(self, r.json()) + return Catalog(self, network.request(f'{self.href}/stac')) def load_collection(self, catalog, collection_id): - r = requests.get(f'{self.href}/collections/{collection_id}', verify=self.VERIFY_SSL) - return Collection(catalog, r.json()) + return Collection(catalog, network.request(f'{self.href}/collections/{collection_id}')) def search_items(self, collections=[], bbox=[], start_time=None, end_time=None, page=1, limit=50, on_next_page=None): @@ -30,17 +26,15 @@ def search_items(self, collections=[], bbox=[], start_time=None, else: time = f'{start_time.strftime("%Y-%m-%dT%H:%M:%SZ")}/{end_time.strftime("%Y-%m-%dT%H:%M:%SZ")}' - r = requests.post(f'{self.href}/stac/search', - json = { - 'collections': [c.id for c in collections], - 'bbox': bbox, - 'time': time, - 'page': page, - 'limit': limit - }, verify=self.VERIFY_SSL) - + body = { + 'collections': [c.id for c in collections], + 'bbox': bbox, + 'time': time, + 'page': page, + 'limit': limit + } - search_result = SearchResult(self, r.json()) + search_result = SearchResult(self, network.request(f'{self.href}/stac/search', data=body)) items = search_result.items if len(items) >= limit: diff --git a/models/item.py b/models/item.py index 6a1b424..97fe522 100644 --- a/models/item.py +++ b/models/item.py @@ -1,9 +1,10 @@ import os import subprocess -import requests import hashlib +import shutil import tempfile from pathlib import Path +from ..utils import network class Item: def __init__(self, catalog=None, json={}): @@ -121,14 +122,10 @@ def download(self, bands, download_directory, stream=False, on_update=None): if on_update is not None: on_update('DOWNLOADING_BAND', data={'band': band, 'bands': bands}) - - r = requests.get(asset.href) + temp_filename = os.path.join(item_download_directory, asset.href.split('/')[-1]) band_filenames.append(temp_filename) - with open(temp_filename, 'wb') as f: - for chunk in r.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) + network.download(asset.href, temp_filename) if on_update is not None: on_update('BUILDING_VRT', data={'bands': bands}) diff --git a/ui/collection_loading_dialog.py b/ui/collection_loading_dialog.py index 9a356c8..93ff30a 100644 --- a/ui/collection_loading_dialog.py +++ b/ui/collection_loading_dialog.py @@ -2,7 +2,6 @@ import threading import time import queue -import requests from PyQt5.QtCore import QThread, pyqtSignal from PyQt5 import uic diff --git a/ui/downloading_dialog.py b/ui/downloading_dialog.py index c4459b2..755e0b7 100644 --- a/ui/downloading_dialog.py +++ b/ui/downloading_dialog.py @@ -2,7 +2,6 @@ import threading import time import queue -import requests from PyQt5.QtCore import QThread, pyqtSignal from PyQt5 import uic diff --git a/ui/item_loading_dialog.py b/ui/item_loading_dialog.py index c2a2310..60e5c10 100644 --- a/ui/item_loading_dialog.py +++ b/ui/item_loading_dialog.py @@ -2,7 +2,6 @@ import threading import time import queue -import requests from PyQt5.QtCore import QThread, pyqtSignal from PyQt5 import uic diff --git a/ui/results_dialog.py b/ui/results_dialog.py index 2cab46f..9ebf2ad 100644 --- a/ui/results_dialog.py +++ b/ui/results_dialog.py @@ -8,10 +8,8 @@ from PyQt5 import QtGui from PyQt5.QtWidgets import QFileDialog -import requests -import shutil - from ..models.item import Item +from ..utils import network FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'results_dialog.ui')) @@ -159,9 +157,5 @@ def __del__(self): self.wait() def run(self): - r = requests.get(self.item.thumbnail_url, stream=True) - if r.status_code == 200: - with open(self.item.thumbnail_path, 'wb') as f: - r.raw.decode_content = True - shutil.copyfileobj(r.raw, f) + network.download(self.item.thumbnail_url, self.item.thumbnail_path) self.finished_signal.emit(self.item) diff --git a/ui/select_bands_dialog.py b/ui/select_bands_dialog.py index 4848653..533b8c8 100644 --- a/ui/select_bands_dialog.py +++ b/ui/select_bands_dialog.py @@ -29,7 +29,6 @@ def __init__(self, data={}, hooks={}, parent=None): def populate_bands_list(self): self._bands_tree_model = QStandardItemModel(self.treeView) for collection in self.collections: - print(collection) collection_node = QStandardItem(f'{collection.title}') for band_name, band_data in collection.bands.items(): if band_data.get('common_name', None) is not None: diff --git a/utils/network.py b/utils/network.py new file mode 100644 index 0000000..28eb530 --- /dev/null +++ b/utils/network.py @@ -0,0 +1,21 @@ +import ssl +import urllib +import shutil +import json + +def request(url, data=None): + context = ssl._create_unverified_context() + r = urllib.request.Request(url) + if data is not None: + body_bytes = json.dumps(data).encode('utf-8') + r.add_header('Content-Type', 'application/json; charset=utf-8') + r.add_header('Content-Length', len(body_bytes)) + r = urllib.request.urlopen(r, body_bytes, context=context) + else: + r = urllib.request.urlopen(r, context=context) + return json.loads(r.read()) + +def download(url, path): + context = ssl._create_unverified_context() + with urllib.request.urlopen(url, context=context) as response, open(path, 'wb') as f: + shutil.copyfileobj(response, f) From 3c5a4b8eb0d6d93015f8c6091693fd9ca3fc2d34 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Wed, 19 Jun 2019 11:47:43 -0500 Subject: [PATCH 11/37] Added basic error handling --- models/api.py | 2 +- stac_browser.py | 15 +++++++-- ui/collection_loading_dialog.py | 22 ++++++++++--- ui/downloading_dialog.py | 22 ++++++++++--- ui/item_loading_dialog.py | 56 ++++++++++++++++++++------------- ui/query_dialog.py | 19 ++++++++++- ui/results_dialog.py | 25 ++++++++++----- ui/select_bands_dialog.py | 2 +- utils/logging.py | 17 ++++++++++ utils/network.py | 14 ++++++--- 10 files changed, 147 insertions(+), 47 deletions(-) create mode 100644 utils/logging.py diff --git a/models/api.py b/models/api.py index 5e7ce86..53c3fff 100644 --- a/models/api.py +++ b/models/api.py @@ -33,7 +33,7 @@ def search_items(self, collections=[], bbox=[], start_time=None, 'page': page, 'limit': limit } - + search_result = SearchResult(self, network.request(f'{self.href}/stac/search', data=body)) items = search_result.items diff --git a/stac_browser.py b/stac_browser.py index a38dfea..c3913e5 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -9,6 +9,7 @@ from .ui.results_dialog import ResultsDialog from .ui.downloading_dialog import DownloadingDialog from .ui.select_bands_dialog import SelectBandsDialog +from .utils.logging import debug, info, warning, error import os.path class STACBrowser: @@ -37,7 +38,7 @@ def __init__(self, iface): }, 'ITEM_LOADING': { 'class': ItemLoadingDialog, - 'hooks': {'on_close': self.on_close, 'on_finished': self.item_load_finished}, + 'hooks': {'on_close': self.on_close, 'on_finished': self.item_load_finished, 'on_error': self.results_error}, 'data': None, 'dialog': None }, @@ -102,13 +103,14 @@ def load_window(self): window = self.windows.get(self.current_window, None) if window is None: - print(f'Window {self.current_window} does not exist') + logging.error(f'Window {self.current_window} does not exist') return if window['dialog'] is None: window['dialog'] = window.get('class')(data=window.get('data'), hooks=window.get('hooks'), - parent=self.iface.mainWindow()) + parent=self.iface.mainWindow(), + iface=self.iface) window['dialog'].show() else: window['dialog'].raise_() @@ -129,6 +131,13 @@ def collection_load_finished(self, apis): self.windows['COLLECTION_LOADING']['dialog'].close() self.load_window() + def results_error(self): + self.windows['ITEM_LOADING']['dialog'].close() + self.windows['ITEM_LOADING']['dialog'] = None + self.windows['ITEM_LOADING']['data'] = None + self.current_window = 'QUERY' + self.load_window() + def item_load_finished(self, items): self.windows['RESULTS']['data'] = { 'items': items } self.current_window = 'RESULTS' diff --git a/ui/collection_loading_dialog.py b/ui/collection_loading_dialog.py index 93ff30a..f0f39ac 100644 --- a/ui/collection_loading_dialog.py +++ b/ui/collection_loading_dialog.py @@ -9,18 +9,22 @@ from qgis.core import QgsLogger from ..utils.config import Config +from ..utils.logging import debug, info, warning, error from ..models.api import API from ..models.catalog import Catalog +from urllib.error import URLError + FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'collection_loading_dialog.ui')) class CollectionLoadingDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None): + def __init__(self, data={}, hooks={}, parent=None, iface=None): super(CollectionLoadingDialog, self).__init__(parent) self.data = data self.hooks = hooks + self.iface = iface self.setupUi(self) @@ -29,6 +33,7 @@ def __init__(self, data={}, hooks={}, parent=None): self.loading_thread = LoadCollectionsThread(Config().get_api_list(), on_progress=self.on_progress_update, + on_error=self.on_error, on_finished=self.on_loading_finished) self.loading_thread.start() @@ -37,6 +42,9 @@ def on_progress_update(self, progress, api): self.label.setText(f'Loading {api}') self.progressBar.setValue(int(progress*100)) + def on_error(self, e, api): + error(self.iface, f'Failed to load {api.href}; {e.reason}') + def on_loading_finished(self, apis): self.progressBar.setValue(100) self.hooks['on_finished'](apis) @@ -48,16 +56,19 @@ def closeEvent(self, event): class LoadCollectionsThread(QThread): progress_signal = pyqtSignal(float, str) + error_signal = pyqtSignal(Exception, API) finished_signal = pyqtSignal(list) - def __init__(self, api_list, on_progress=None, on_finished=None): + def __init__(self, api_list, on_progress=None, on_error=None, on_finished=None): QThread.__init__(self) self._running = True self.api_list = api_list self.on_progress=on_progress + self.on_error = on_error self.on_finished=on_finished self.progress_signal.connect(self.on_progress) + self.error_signal.connect(self.on_error) self.finished_signal.connect(self.on_finished) def run(self): @@ -68,8 +79,11 @@ def run(self): progress = (float(i) / float(len(self.api_list))) self.progress_signal.emit(progress, api_url) api = API(api_url) - api.catalog.load_collections() - apis.append(api) + try: + api.catalog.load_collections() + apis.append(api) + except URLError as e: + self.error_signal.emit(e, api) self.finished_signal.emit(apis) self.quit() diff --git a/ui/downloading_dialog.py b/ui/downloading_dialog.py index 755e0b7..d9f331d 100644 --- a/ui/downloading_dialog.py +++ b/ui/downloading_dialog.py @@ -17,15 +17,19 @@ from ..models.catalog import Catalog from ..models.item import Item +from urllib.error import URLError +from ..utils.logging import debug, info, warning, error + FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'downloading_dialog.ui')) class DownloadingDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None): + def __init__(self, data={}, hooks={}, parent=None, iface=None): super(DownloadingDialog, self).__init__(parent) self.data = data self.hooks = hooks + self.iface = iface self.setupUi(self) @@ -36,6 +40,7 @@ def __init__(self, data={}, hooks={}, parent=None): self.download_directory, self.stream, on_progress=self.on_progress_update, + on_error=self.on_error, on_add_layer=self.on_add_layer, on_finished=self.on_downloading_finished) @@ -56,6 +61,9 @@ def download_directory(self): @property def stream(self): return self.data.get('stream', False) + + def on_error(self, item, e): + error(self.iface, f'Failed to load {item.id}; {e.reason}') def on_add_layer(self, current_item, total_items, item, download_directory): self.on_progress_update(current_item, total_items, 'ADDING_TO_LAYERS') @@ -95,10 +103,11 @@ def closeEvent(self, event): class DownloadItemsThread(QThread): progress_signal = pyqtSignal(int, int, str, dict) + error_signal = pyqtSignal(Item, Exception) add_layer_signal = pyqtSignal(int, int, Item, str) finished_signal = pyqtSignal() - def __init__(self, items, bands, download_directory, stream, on_progress=None, on_add_layer=None, on_finished=None): + def __init__(self, items, bands, download_directory, stream, on_progress=None, on_error=None, on_add_layer=None, on_finished=None): QThread.__init__(self) self._running = True self.items = items @@ -106,12 +115,14 @@ def __init__(self, items, bands, download_directory, stream, on_progress=None, o self.download_directory = download_directory self.stream = stream self.on_progress=on_progress + self.on_error = on_error self.on_add_layer = on_add_layer self.on_finished=on_finished self._current_item = None self.progress_signal.connect(self.on_progress) + self.error_signal.connect(self.on_error) self.add_layer_signal.connect(self.on_add_layer) self.finished_signal.connect(self.on_finished) @@ -125,8 +136,11 @@ def run(self): for collection_band in self.bands: if collection_band['collection'] == item.collection: bands = collection_band['bands'] - item.download(bands, self.download_directory, self.stream, on_update=self.on_update) - self.add_layer_signal.emit(i, len(self.items), item, self.download_directory) + try: + item.download(bands, self.download_directory, self.stream, on_update=self.on_update) + self.add_layer_signal.emit(i, len(self.items), item, self.download_directory) + except URLError as e: + self.error_signal.emit(item, e) self.finished_signal.emit() def on_update(self, state, data={}): diff --git a/ui/item_loading_dialog.py b/ui/item_loading_dialog.py index 60e5c10..0e92b22 100644 --- a/ui/item_loading_dialog.py +++ b/ui/item_loading_dialog.py @@ -10,16 +10,19 @@ from qgis.core import QgsLogger from ..utils.config import Config from ..models.catalog import Catalog +from ..utils.logging import debug, info, warning, error +from urllib.error import HTTPError, URLError FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'item_loading_dialog.ui')) class ItemLoadingDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None): + def __init__(self, data={}, hooks={}, parent=None, iface=None): super(ItemLoadingDialog, self).__init__(parent) self.data = data self.hooks = hooks + self.iface = iface self.setupUi(self) self.setFixedSize(self.size()) @@ -29,6 +32,7 @@ def __init__(self, data={}, hooks={}, parent=None): self.data['start_time'], self.data['end_time'], on_progress=self.on_progress, + on_error=self.on_error, on_finished=self.on_finished) self.loading_thread.start() @@ -37,6 +41,10 @@ def on_progress(self, collections, current_page): collection_label = ', '.join([c.title for c in collections]) self.loadingLabel.setText(f'Searching {collection_label}\nPage {current_page}...') + def on_error(self, e): + error(self.iface, f'Network Error: {e.reason}') + self.hooks['on_error']() + def on_finished(self, items): self.hooks['on_finished'](items) @@ -47,10 +55,11 @@ def closeEvent(self, event): class LoadItemsThread(QThread): progress_signal = pyqtSignal(list, int) + error_signal = pyqtSignal(Exception) finished_signal = pyqtSignal(list) def __init__(self, catalog_collections, extent, start_time, end_time, - on_progress=None, on_finished=None): + on_progress=None, on_error=None, on_finished=None): QThread.__init__(self) self._running = True self.current_page = 0 @@ -59,11 +68,13 @@ def __init__(self, catalog_collections, extent, start_time, end_time, self.extent = extent self.start_time = start_time self.end_time = end_time - self.on_progress=on_progress - self.on_finished=on_finished + self.on_progress = on_progress + self.on_error = on_error + self.on_finished = on_finished self._current_collections = [] - + self.progress_signal.connect(self.on_progress) + self.error_signal.connect(self.on_error) self.finished_signal.connect(self.on_finished) def __del__(self): @@ -73,22 +84,25 @@ def stop(self): self._running = False def run(self): - all_items = [] - for catalog_collection in self.catalog_collections: - if not self._running: - return - - catalog = catalog_collection['catalog'] - collections = catalog_collection['collections'] - self._current_collections = collections - - items = catalog.api.search_items(collections, - self.extent, - self.start_time, - self.end_time, - on_next_page=self.on_next_page) - all_items.extend(items) - self.finished_signal.emit(all_items) + try: + all_items = [] + for catalog_collection in self.catalog_collections: + if not self._running: + return + + catalog = catalog_collection['catalog'] + collections = catalog_collection['collections'] + self._current_collections = collections + + items = catalog.api.search_items(collections, + self.extent, + self.start_time, + self.end_time, + on_next_page=self.on_next_page) + all_items.extend(items) + self.finished_signal.emit(all_items) + except URLError as e: + self.error_signal.emit(e) def on_next_page(self): self.current_page += 1 diff --git a/ui/query_dialog.py b/ui/query_dialog.py index 5802067..768a8f0 100644 --- a/ui/query_dialog.py +++ b/ui/query_dialog.py @@ -8,16 +8,18 @@ from PyQt5.QtWidgets import QTreeWidgetItem from qgis.core import QgsProject, QgsMapLayer +from ..utils.logging import debug, info, warning, error FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'query_dialog.ui')) class QueryDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None): + def __init__(self, data={}, hooks={}, parent=None, iface=None): super(QueryDialog, self).__init__(parent) self.data = data self.hooks = hooks + self.iface = iface self.setupUi(self) self._extent_layers = None @@ -58,6 +60,17 @@ def populate_collection_list(self): collection_node.setFlags(collection_node.flags() | QtCore.Qt.ItemIsUserCheckable) collection_node.setCheckState(0, QtCore.Qt.Unchecked) + def validate(self): + valid = True + if self.extentLayer.currentIndex() < 0: + error(self.iface, "Extent layer is not valid") + valid = False + start_time, end_time = self.time_period + if start_time > end_time: + error(self.iface, "Start time can not be after end time") + valid = False + return valid + @property def catalog_selections(self): catalog_collections = [] @@ -104,6 +117,10 @@ def time_period(self): datetime.strptime(self.endPeriod.text(), '%Y-%m-%d %H:%MZ')) def on_search_clicked(self): + valid = self.validate() + if not valid: + return + self.hooks['on_search'](self.catalog_selections, self.extent_layer, self.time_period) diff --git a/ui/results_dialog.py b/ui/results_dialog.py index 9ebf2ad..0ff7c50 100644 --- a/ui/results_dialog.py +++ b/ui/results_dialog.py @@ -11,12 +11,14 @@ from ..models.item import Item from ..utils import network +from urllib.error import URLError + FORM_CLASS, _ = uic.loadUiType(os.path.join( os.path.dirname(__file__), 'results_dialog.ui')) class ResultsDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None): + def __init__(self, data={}, hooks={}, parent=None, iface=None): super(ResultsDialog, self).__init__(parent) self.data = data self.hooks = hooks @@ -102,20 +104,24 @@ def on_list_clicked(self, index): def select_item(self, item): self._selected_item = item - self.set_preview(item) + self.set_preview(item, False) self.populate_item_details(item) - def on_image_loaded(self, item): + def on_image_loaded(self, item, error): if self._selected_item != item: return - self.set_preview(item) + self.set_preview(item, error) - def set_preview(self, item): + def set_preview(self, item, error): if item.thumbnail_url is None: self.imageView.setText('No Preview Available') return + if error: + self.imageView.setText('Error Loading Preview') + return + if not os.path.exists(item.thumbnail_path): self.imageView.setText('Loading Preview...') self.loading_thread = LoadPreviewThread(item, on_image_loaded=self.on_image_loaded) @@ -144,7 +150,7 @@ def on_back_clicked(self): class LoadPreviewThread(QThread): - finished_signal = pyqtSignal(Item) + finished_signal = pyqtSignal(Item, bool) def __init__(self, item, on_image_loaded=None): QThread.__init__(self) @@ -157,5 +163,8 @@ def __del__(self): self.wait() def run(self): - network.download(self.item.thumbnail_url, self.item.thumbnail_path) - self.finished_signal.emit(self.item) + try: + network.download(self.item.thumbnail_url, self.item.thumbnail_path) + self.finished_signal.emit(self.item, False) + except URLError as e: + self.finished_signal.emit(self.item, True) diff --git a/ui/select_bands_dialog.py b/ui/select_bands_dialog.py index 533b8c8..a15da21 100644 --- a/ui/select_bands_dialog.py +++ b/ui/select_bands_dialog.py @@ -13,7 +13,7 @@ class SelectBandsDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None): + def __init__(self, data={}, hooks={}, parent=None, iface=None): super(SelectBandsDialog, self).__init__(parent) self.data = data self.hooks = hooks diff --git a/utils/logging.py b/utils/logging.py new file mode 100644 index 0000000..7966bea --- /dev/null +++ b/utils/logging.py @@ -0,0 +1,17 @@ +from qgis.core import QgsMessageLog, Qgis + +def debug(message): + QgsMessageLog.logMessage(message, level=Qgis.Info) + +def info(iface, message, duration=5): + QgsMessageLog.logMessage(message, level=Qgis.Info) + iface.messageBar().pushMessage("Info", message, level=Qgis.Info, duration=duration) + +def warning(iface, message, duration=5): + QgsMessageLog.logMessage(message, level=Qgis.Warning) + iface.messageBar().pushMessage("Warning", message, level=Qgis.Warning, duration=duration) + +def error(iface, message, duration=5): + QgsMessageLog.logMessage(message, level=Qgis.Critical) + iface.messageBar().pushMessage("Error", message, level=Qgis.Critical, duration=duration) + diff --git a/utils/network.py b/utils/network.py index 28eb530..4fbbcda 100644 --- a/utils/network.py +++ b/utils/network.py @@ -2,20 +2,26 @@ import urllib import shutil import json +import os def request(url, data=None): - context = ssl._create_unverified_context() + if os.environ.get('STAC_DEBUG', False): + context = ssl._create_unverified_context() + else: + context = None + r = urllib.request.Request(url) if data is not None: body_bytes = json.dumps(data).encode('utf-8') r.add_header('Content-Type', 'application/json; charset=utf-8') r.add_header('Content-Length', len(body_bytes)) - r = urllib.request.urlopen(r, body_bytes, context=context) + r = urllib.request.urlopen(r, body_bytes, context=context, timeout=5) else: - r = urllib.request.urlopen(r, context=context) + r = urllib.request.urlopen(r, context=context, timeout=5) + return json.loads(r.read()) def download(url, path): context = ssl._create_unverified_context() - with urllib.request.urlopen(url, context=context) as response, open(path, 'wb') as f: + with urllib.request.urlopen(url, context=context, timeout=5) as response, open(path, 'wb') as f: shutil.copyfileobj(response, f) From 8e26d1d54b41f8cdc5805e07b09aae3ab6f9cbc6 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Wed, 19 Jun 2019 13:15:54 -0500 Subject: [PATCH 12/37] Separated views and controllers, separated threads --- controllers/collection_loading_dialog.py | 45 +++++++++ {ui => controllers}/downloading_dialog.py | 64 +----------- controllers/item_loading_dialog.py | 47 +++++++++ {ui => controllers}/query_dialog.py | 12 +-- {ui => controllers}/results_dialog.py | 39 ++------ {ui => controllers}/select_bands_dialog.py | 22 ++--- pb_tool.cfg | 2 +- stac_browser.py | 12 +-- threads/download_items_thread.py | 45 +++++++++ threads/load_collections_thread.py | 34 +++++++ threads/load_items_thread.py | 47 +++++++++ threads/load_preview_thread.py | 21 ++++ ui/collection_loading_dialog.py | 92 ----------------- ui/item_loading_dialog.py | 109 --------------------- utils/config.py | 35 ++++++- utils/ui.py | 6 ++ {ui => views}/collection_loading_dialog.ui | 0 {ui => views}/downloading_dialog.ui | 0 {ui => views}/item_loading_dialog.ui | 0 {ui => views}/query_dialog.ui | 0 {ui => views}/results_dialog.ui | 0 {ui => views}/select_bands_dialog.ui | 0 22 files changed, 313 insertions(+), 319 deletions(-) create mode 100644 controllers/collection_loading_dialog.py rename {ui => controllers}/downloading_dialog.py (59%) create mode 100644 controllers/item_loading_dialog.py rename {ui => controllers}/query_dialog.py (96%) rename {ui => controllers}/results_dialog.py (84%) rename {ui => controllers}/select_bands_dialog.py (95%) create mode 100644 threads/download_items_thread.py create mode 100644 threads/load_collections_thread.py create mode 100644 threads/load_items_thread.py create mode 100644 threads/load_preview_thread.py delete mode 100644 ui/collection_loading_dialog.py delete mode 100644 ui/item_loading_dialog.py create mode 100644 utils/ui.py rename {ui => views}/collection_loading_dialog.ui (100%) rename {ui => views}/downloading_dialog.ui (100%) rename {ui => views}/item_loading_dialog.ui (100%) rename {ui => views}/query_dialog.ui (100%) rename {ui => views}/results_dialog.ui (100%) rename {ui => views}/select_bands_dialog.ui (100%) diff --git a/controllers/collection_loading_dialog.py b/controllers/collection_loading_dialog.py new file mode 100644 index 0000000..0d494d7 --- /dev/null +++ b/controllers/collection_loading_dialog.py @@ -0,0 +1,45 @@ +from PyQt5 import uic, QtWidgets + +from ..utils.config import Config +from ..utils.logging import debug, info, warning, error +from ..utils import ui +from ..threads.load_collections_thread import LoadCollectionsThread + + +FORM_CLASS, _ = uic.loadUiType(ui.path('collection_loading_dialog.ui')) + +class CollectionLoadingDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None, iface=None): + super(CollectionLoadingDialog, self).__init__(parent) + + self.data = data + self.hooks = hooks + self.iface = iface + + self.setupUi(self) + self.setFixedSize(self.size()) + + + self.loading_thread = LoadCollectionsThread(Config().apis, + on_progress=self.on_progress_update, + on_error=self.on_error, + on_finished=self.on_loading_finished) + + self.loading_thread.start() + + def on_progress_update(self, progress, api): + self.label.setText(f'Loading {api}') + self.progressBar.setValue(int(progress*100)) + + def on_error(self, e, api): + error(self.iface, f'Failed to load {api.href}; {e.reason}') + + def on_loading_finished(self, apis): + self.progressBar.setValue(100) + self.hooks['on_finished'](apis) + + def closeEvent(self, event): + if event.spontaneous(): + self.loading_thread.terminate() + self.hooks['on_close']() + diff --git a/ui/downloading_dialog.py b/controllers/downloading_dialog.py similarity index 59% rename from ui/downloading_dialog.py rename to controllers/downloading_dialog.py index d9f331d..ed58e01 100644 --- a/ui/downloading_dialog.py +++ b/controllers/downloading_dialog.py @@ -1,38 +1,28 @@ import os -import threading -import time -import queue -from PyQt5.QtCore import QThread, pyqtSignal -from PyQt5 import uic -from PyQt5 import QtWidgets +from PyQt5 import uic, QtWidgets from qgis.core import ( QgsRasterLayer, QgsProject ) -from qgis.core import QgsLogger -from ..utils.config import Config -from ..models.catalog import Catalog -from ..models.item import Item - -from urllib.error import URLError +from ..utils import ui from ..utils.logging import debug, info, warning, error +from ..threads.download_items_thread import DownloadItemsThread -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'downloading_dialog.ui')) +FORM_CLASS, _ = uic.loadUiType(ui.path('downloading_dialog.ui')) class DownloadingDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(DownloadingDialog, self).__init__(parent) + self.data = data self.hooks = hooks self.iface = iface self.setupUi(self) - self.setFixedSize(self.size()) self.loading_thread = DownloadItemsThread(self.items, @@ -101,48 +91,4 @@ def closeEvent(self, event): self.loading_thread.terminate() self.hooks['on_close']() -class DownloadItemsThread(QThread): - progress_signal = pyqtSignal(int, int, str, dict) - error_signal = pyqtSignal(Item, Exception) - add_layer_signal = pyqtSignal(int, int, Item, str) - finished_signal = pyqtSignal() - - def __init__(self, items, bands, download_directory, stream, on_progress=None, on_error=None, on_add_layer=None, on_finished=None): - QThread.__init__(self) - self._running = True - self.items = items - self.bands = bands - self.download_directory = download_directory - self.stream = stream - self.on_progress=on_progress - self.on_error = on_error - self.on_add_layer = on_add_layer - self.on_finished=on_finished - - self._current_item = None - - self.progress_signal.connect(self.on_progress) - self.error_signal.connect(self.on_error) - self.add_layer_signal.connect(self.on_add_layer) - self.finished_signal.connect(self.on_finished) - - def __del__(self): - self.wait() - - def run(self): - for i, item in enumerate(self.items): - self._current_item = i - bands = [] - for collection_band in self.bands: - if collection_band['collection'] == item.collection: - bands = collection_band['bands'] - try: - item.download(bands, self.download_directory, self.stream, on_update=self.on_update) - self.add_layer_signal.emit(i, len(self.items), item, self.download_directory) - except URLError as e: - self.error_signal.emit(item, e) - self.finished_signal.emit() - - def on_update(self, state, data={}): - self.progress_signal.emit(self._current_item, len(self.items), state, data) diff --git a/controllers/item_loading_dialog.py b/controllers/item_loading_dialog.py new file mode 100644 index 0000000..fa05e94 --- /dev/null +++ b/controllers/item_loading_dialog.py @@ -0,0 +1,47 @@ +from PyQt5 import uic, QtWidgets + +from ..utils.config import Config +from ..utils import ui +from ..utils.logging import debug, info, warning, error +from ..threads.load_items_thread import LoadItemsThread + + +FORM_CLASS, _ = uic.loadUiType(ui.path('item_loading_dialog.ui')) + +class ItemLoadingDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None, iface=None): + super(ItemLoadingDialog, self).__init__(parent) + + self.data = data + self.hooks = hooks + self.iface = iface + + self.setupUi(self) + self.setFixedSize(self.size()) + + self.loading_thread = LoadItemsThread(self.data['catalog_collections'], + self.data['extent'], + self.data['start_time'], + self.data['end_time'], + on_progress=self.on_progress, + on_error=self.on_error, + on_finished=self.on_finished) + + self.loading_thread.start() + + def on_progress(self, collections, current_page): + collection_label = ', '.join([c.title for c in collections]) + self.loadingLabel.setText(f'Searching {collection_label}\nPage {current_page}...') + + def on_error(self, e): + error(self.iface, f'Network Error: {e.reason}') + self.hooks['on_error']() + + def on_finished(self, items): + self.hooks['on_finished'](items) + + def closeEvent(self, event): + if event.spontaneous(): + self.loading_thread.terminate() + self.hooks['on_close']() + diff --git a/ui/query_dialog.py b/controllers/query_dialog.py similarity index 96% rename from ui/query_dialog.py rename to controllers/query_dialog.py index 768a8f0..289de26 100644 --- a/ui/query_dialog.py +++ b/controllers/query_dialog.py @@ -1,25 +1,25 @@ -import os from datetime import datetime -from PyQt5 import uic -from PyQt5 import QtWidgets -from PyQt5 import QtCore +from PyQt5 import uic, QtWidgets, QtCore from PyQt5.QtGui import QStandardItemModel, QStandardItem from PyQt5.QtWidgets import QTreeWidgetItem from qgis.core import QgsProject, QgsMapLayer + +from ..utils import ui from ..utils.logging import debug, info, warning, error -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'query_dialog.ui')) +FORM_CLASS, _ = uic.loadUiType(ui.path('query_dialog.ui')) class QueryDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(QueryDialog, self).__init__(parent) + self.data = data self.hooks = hooks self.iface = iface + self.setupUi(self) self._extent_layers = None diff --git a/ui/results_dialog.py b/controllers/results_dialog.py similarity index 84% rename from ui/results_dialog.py rename to controllers/results_dialog.py index 0ff7c50..1467cad 100644 --- a/ui/results_dialog.py +++ b/controllers/results_dialog.py @@ -1,27 +1,23 @@ import os -import time -from PyQt5.QtCore import QThread, pyqtSignal -from PyQt5 import uic -from PyQt5 import QtWidgets -from PyQt5 import QtCore -from PyQt5 import QtGui +from PyQt5 import uic, QtWidgets, QtCore, QtGui from PyQt5.QtWidgets import QFileDialog from ..models.item import Item -from ..utils import network +from ..utils import ui +from ..threads.load_preview_thread import LoadPreviewThread -from urllib.error import URLError - -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'results_dialog.ui')) +FORM_CLASS, _ = uic.loadUiType(ui.path('results_dialog.ui')) class ResultsDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(ResultsDialog, self).__init__(parent) + self.data = data self.hooks = hooks + self.iface = iface + self.setupUi(self) self._item_list_model = None @@ -147,24 +143,3 @@ def closeEvent(self, event): def on_back_clicked(self): self.hooks['on_back']() - - -class LoadPreviewThread(QThread): - finished_signal = pyqtSignal(Item, bool) - - def __init__(self, item, on_image_loaded=None): - QThread.__init__(self) - self.item = item - self.on_image_loaded=on_image_loaded - - self.finished_signal.connect(self.on_image_loaded) - - def __del__(self): - self.wait() - - def run(self): - try: - network.download(self.item.thumbnail_url, self.item.thumbnail_path) - self.finished_signal.emit(self.item, False) - except URLError as e: - self.finished_signal.emit(self.item, True) diff --git a/ui/select_bands_dialog.py b/controllers/select_bands_dialog.py similarity index 95% rename from ui/select_bands_dialog.py rename to controllers/select_bands_dialog.py index a15da21..26f5af9 100644 --- a/ui/select_bands_dialog.py +++ b/controllers/select_bands_dialog.py @@ -1,6 +1,3 @@ -import os -from datetime import datetime - from PyQt5 import uic from PyQt5 import QtWidgets from PyQt5 import QtCore @@ -8,15 +5,19 @@ from qgis.core import QgsProject, QgsMapLayer -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'select_bands_dialog.ui')) +from ..utils import ui + +FORM_CLASS, _ = uic.loadUiType(ui.path('select_bands_dialog.ui')) class SelectBandsDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(SelectBandsDialog, self).__init__(parent) + self.data = data self.hooks = hooks + self.iface = iface + self.setupUi(self) self._bands_tree_model = None @@ -42,12 +43,6 @@ def populate_bands_list(self): self.treeView.setModel(self._bands_tree_model) self.treeView.expandAll() - def on_download_clicked(self): - self.accept() - - def on_cancel_clicked(self): - self.reject() - @property def items(self): return self.data.get('items', []) @@ -88,6 +83,11 @@ def selected_bands(self): return collection_band_list + def on_download_clicked(self): + self.accept() + + def on_cancel_clicked(self): + self.reject() def closeEvent(self, event): if event.spontaneous(): diff --git a/pb_tool.cfg b/pb_tool.cfg index a61b553..ce19008 100644 --- a/pb_tool.cfg +++ b/pb_tool.cfg @@ -64,7 +64,7 @@ extras: metadata.txt # Other directories to be deployed with the plugin. # These must be subdirectories under the plugin directory -extra_dirs: ui assets models utils +extra_dirs: models views controllers threads assets utils # ISO code(s) for any locales (translations), separated by spaces. # Corresponding .ts files must exist in the i18n directory diff --git a/stac_browser.py b/stac_browser.py index c3913e5..4635be5 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -3,12 +3,12 @@ from PyQt5.QtWidgets import QAction, QApplication from .resources import * -from .ui.collection_loading_dialog import CollectionLoadingDialog -from .ui.query_dialog import QueryDialog -from .ui.item_loading_dialog import ItemLoadingDialog -from .ui.results_dialog import ResultsDialog -from .ui.downloading_dialog import DownloadingDialog -from .ui.select_bands_dialog import SelectBandsDialog +from .controllers.collection_loading_dialog import CollectionLoadingDialog +from .controllers.query_dialog import QueryDialog +from .controllers.item_loading_dialog import ItemLoadingDialog +from .controllers.results_dialog import ResultsDialog +from .controllers.downloading_dialog import DownloadingDialog +from .controllers.select_bands_dialog import SelectBandsDialog from .utils.logging import debug, info, warning, error import os.path diff --git a/threads/download_items_thread.py b/threads/download_items_thread.py new file mode 100644 index 0000000..7c84dcb --- /dev/null +++ b/threads/download_items_thread.py @@ -0,0 +1,45 @@ +from PyQt5.QtCore import QThread, pyqtSignal +from urllib.error import URLError +from ..models.item import Item + +class DownloadItemsThread(QThread): + progress_signal = pyqtSignal(int, int, str, dict) + error_signal = pyqtSignal(Item, Exception) + add_layer_signal = pyqtSignal(int, int, Item, str) + finished_signal = pyqtSignal() + + def __init__(self, items, bands, download_directory, stream, on_progress=None, on_error=None, on_add_layer=None, on_finished=None): + QThread.__init__(self) + + self.items = items + self.bands = bands + self.download_directory = download_directory + self.stream = stream + self.on_progress=on_progress + self.on_error = on_error + self.on_add_layer = on_add_layer + self.on_finished=on_finished + + self._current_item = None + + self.progress_signal.connect(self.on_progress) + self.error_signal.connect(self.on_error) + self.add_layer_signal.connect(self.on_add_layer) + self.finished_signal.connect(self.on_finished) + + def run(self): + for i, item in enumerate(self.items): + self._current_item = i + bands = [] + for collection_band in self.bands: + if collection_band['collection'] == item.collection: + bands = collection_band['bands'] + try: + item.download(bands, self.download_directory, self.stream, on_update=self.on_update) + self.add_layer_signal.emit(i, len(self.items), item, self.download_directory) + except URLError as e: + self.error_signal.emit(item, e) + self.finished_signal.emit() + + def on_update(self, state, data={}): + self.progress_signal.emit(self._current_item, len(self.items), state, data) diff --git a/threads/load_collections_thread.py b/threads/load_collections_thread.py new file mode 100644 index 0000000..57a9861 --- /dev/null +++ b/threads/load_collections_thread.py @@ -0,0 +1,34 @@ +from PyQt5.QtCore import QThread, pyqtSignal +from urllib.error import URLError +from ..models.api import API + +class LoadCollectionsThread(QThread): + progress_signal = pyqtSignal(float, str) + error_signal = pyqtSignal(Exception, API) + finished_signal = pyqtSignal(list) + + def __init__(self, api_list, on_progress=None, on_error=None, on_finished=None): + QThread.__init__(self) + + self.api_list = api_list + self.on_progress=on_progress + self.on_error = on_error + self.on_finished=on_finished + + self.progress_signal.connect(self.on_progress) + self.error_signal.connect(self.on_error) + self.finished_signal.connect(self.on_finished) + + def run(self): + apis = [] + for i, api_url in enumerate(self.api_list): + progress = (float(i) / float(len(self.api_list))) + self.progress_signal.emit(progress, api_url) + api = API(api_url) + try: + api.catalog.load_collections() + apis.append(api) + except URLError as e: + self.error_signal.emit(e, api) + + self.finished_signal.emit(apis) diff --git a/threads/load_items_thread.py b/threads/load_items_thread.py new file mode 100644 index 0000000..6ec0aae --- /dev/null +++ b/threads/load_items_thread.py @@ -0,0 +1,47 @@ +from PyQt5.QtCore import QThread, pyqtSignal +from urllib.error import URLError + +class LoadItemsThread(QThread): + progress_signal = pyqtSignal(list, int) + error_signal = pyqtSignal(Exception) + finished_signal = pyqtSignal(list) + + def __init__(self, catalog_collections, extent, start_time, end_time, + on_progress=None, on_error=None, on_finished=None): + QThread.__init__(self) + self.current_page = 0 + + self.catalog_collections = catalog_collections + self.extent = extent + self.start_time = start_time + self.end_time = end_time + self.on_progress = on_progress + self.on_error = on_error + self.on_finished = on_finished + self._current_collections = [] + + self.progress_signal.connect(self.on_progress) + self.error_signal.connect(self.on_error) + self.finished_signal.connect(self.on_finished) + + def run(self): + try: + all_items = [] + for catalog_collection in self.catalog_collections: + catalog = catalog_collection['catalog'] + collections = catalog_collection['collections'] + self._current_collections = collections + + items = catalog.api.search_items(collections, + self.extent, + self.start_time, + self.end_time, + on_next_page=self.on_next_page) + all_items.extend(items) + self.finished_signal.emit(all_items) + except URLError as e: + self.error_signal.emit(e) + + def on_next_page(self): + self.current_page += 1 + self.progress_signal.emit(self._current_collections, self.current_page) diff --git a/threads/load_preview_thread.py b/threads/load_preview_thread.py new file mode 100644 index 0000000..ceea66a --- /dev/null +++ b/threads/load_preview_thread.py @@ -0,0 +1,21 @@ +from PyQt5.QtCore import QThread, pyqtSignal +from urllib.error import URLError +from ..utils import network +from ..models.item import Item + +class LoadPreviewThread(QThread): + finished_signal = pyqtSignal(Item, bool) + + def __init__(self, item, on_image_loaded=None): + QThread.__init__(self) + self.item = item + self.on_image_loaded=on_image_loaded + + self.finished_signal.connect(self.on_image_loaded) + + def run(self): + try: + network.download(self.item.thumbnail_url, self.item.thumbnail_path) + self.finished_signal.emit(self.item, False) + except URLError as e: + self.finished_signal.emit(self.item, True) diff --git a/ui/collection_loading_dialog.py b/ui/collection_loading_dialog.py deleted file mode 100644 index f0f39ac..0000000 --- a/ui/collection_loading_dialog.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import threading -import time -import queue - -from PyQt5.QtCore import QThread, pyqtSignal -from PyQt5 import uic -from PyQt5 import QtWidgets - -from qgis.core import QgsLogger -from ..utils.config import Config -from ..utils.logging import debug, info, warning, error -from ..models.api import API -from ..models.catalog import Catalog - -from urllib.error import URLError - -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'collection_loading_dialog.ui')) - - -class CollectionLoadingDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None, iface=None): - super(CollectionLoadingDialog, self).__init__(parent) - self.data = data - self.hooks = hooks - self.iface = iface - - self.setupUi(self) - - self.setFixedSize(self.size()) - - - self.loading_thread = LoadCollectionsThread(Config().get_api_list(), - on_progress=self.on_progress_update, - on_error=self.on_error, - on_finished=self.on_loading_finished) - - self.loading_thread.start() - - def on_progress_update(self, progress, api): - self.label.setText(f'Loading {api}') - self.progressBar.setValue(int(progress*100)) - - def on_error(self, e, api): - error(self.iface, f'Failed to load {api.href}; {e.reason}') - - def on_loading_finished(self, apis): - self.progressBar.setValue(100) - self.hooks['on_finished'](apis) - - def closeEvent(self, event): - if event.spontaneous(): - self.loading_thread.terminate() - self.hooks['on_close']() - -class LoadCollectionsThread(QThread): - progress_signal = pyqtSignal(float, str) - error_signal = pyqtSignal(Exception, API) - finished_signal = pyqtSignal(list) - - def __init__(self, api_list, on_progress=None, on_error=None, on_finished=None): - QThread.__init__(self) - self._running = True - self.api_list = api_list - self.on_progress=on_progress - self.on_error = on_error - self.on_finished=on_finished - - self.progress_signal.connect(self.on_progress) - self.error_signal.connect(self.on_error) - self.finished_signal.connect(self.on_finished) - - def run(self): - apis = [] - for i, api_url in enumerate(self.api_list): - if not self._running: - return - progress = (float(i) / float(len(self.api_list))) - self.progress_signal.emit(progress, api_url) - api = API(api_url) - try: - api.catalog.load_collections() - apis.append(api) - except URLError as e: - self.error_signal.emit(e, api) - - self.finished_signal.emit(apis) - self.quit() - - def stop(self): - self._running = False diff --git a/ui/item_loading_dialog.py b/ui/item_loading_dialog.py deleted file mode 100644 index 0e92b22..0000000 --- a/ui/item_loading_dialog.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import threading -import time -import queue - -from PyQt5.QtCore import QThread, pyqtSignal -from PyQt5 import uic -from PyQt5 import QtWidgets - -from qgis.core import QgsLogger -from ..utils.config import Config -from ..models.catalog import Catalog -from ..utils.logging import debug, info, warning, error -from urllib.error import HTTPError, URLError - -FORM_CLASS, _ = uic.loadUiType(os.path.join( - os.path.dirname(__file__), 'item_loading_dialog.ui')) - - -class ItemLoadingDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None, iface=None): - super(ItemLoadingDialog, self).__init__(parent) - self.data = data - self.hooks = hooks - self.iface = iface - self.setupUi(self) - - self.setFixedSize(self.size()) - - self.loading_thread = LoadItemsThread(self.data['catalog_collections'], - self.data['extent'], - self.data['start_time'], - self.data['end_time'], - on_progress=self.on_progress, - on_error=self.on_error, - on_finished=self.on_finished) - - self.loading_thread.start() - - def on_progress(self, collections, current_page): - collection_label = ', '.join([c.title for c in collections]) - self.loadingLabel.setText(f'Searching {collection_label}\nPage {current_page}...') - - def on_error(self, e): - error(self.iface, f'Network Error: {e.reason}') - self.hooks['on_error']() - - def on_finished(self, items): - self.hooks['on_finished'](items) - - def closeEvent(self, event): - if event.spontaneous(): - self.loading_thread.terminate() - self.hooks['on_close']() - -class LoadItemsThread(QThread): - progress_signal = pyqtSignal(list, int) - error_signal = pyqtSignal(Exception) - finished_signal = pyqtSignal(list) - - def __init__(self, catalog_collections, extent, start_time, end_time, - on_progress=None, on_error=None, on_finished=None): - QThread.__init__(self) - self._running = True - self.current_page = 0 - - self.catalog_collections = catalog_collections - self.extent = extent - self.start_time = start_time - self.end_time = end_time - self.on_progress = on_progress - self.on_error = on_error - self.on_finished = on_finished - self._current_collections = [] - - self.progress_signal.connect(self.on_progress) - self.error_signal.connect(self.on_error) - self.finished_signal.connect(self.on_finished) - - def __del__(self): - self.wait() - - def stop(self): - self._running = False - - def run(self): - try: - all_items = [] - for catalog_collection in self.catalog_collections: - if not self._running: - return - - catalog = catalog_collection['catalog'] - collections = catalog_collection['collections'] - self._current_collections = collections - - items = catalog.api.search_items(collections, - self.extent, - self.start_time, - self.end_time, - on_next_page=self.on_next_page) - all_items.extend(items) - self.finished_signal.emit(all_items) - except URLError as e: - self.error_signal.emit(e) - - def on_next_page(self): - self.current_page += 1 - self.progress_signal.emit(self._current_collections, self.current_page) diff --git a/utils/config.py b/utils/config.py index ec2246e..ea310a8 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,8 +1,37 @@ +import os +import json + class Config: STAC_APIS = ['https://stac.boundlessgeo.io', 'https://sat-api.developmentseed.org'] def __init__(self): - pass + self._json = None + self.load() + + def load(self): + if not os.path.exists(self.path): + self._json = {} + self.save() + else: + with open(self.path, 'r') as f: + self._json = json.load(f) + + def save(self): + config = { + 'apis': self.apis + } + with open(self.path, 'w') as f: + f.write(json.dumps(config)) + + @property + def path(self): + return os.path.join(os.path.split(os.path.dirname(__file__))[0], 'config.json') + + @property + def apis(self): + apis = self._json.get('apis', None) + + if apis is None: + apis = self.STAC_APIS - def get_api_list(self): - return self.STAC_APIS + return apis diff --git a/utils/ui.py b/utils/ui.py new file mode 100644 index 0000000..2271ed5 --- /dev/null +++ b/utils/ui.py @@ -0,0 +1,6 @@ +import os + +def path(filename): + return os.path.join(os.path.split(os.path.dirname(__file__))[0], + 'views', + filename) diff --git a/ui/collection_loading_dialog.ui b/views/collection_loading_dialog.ui similarity index 100% rename from ui/collection_loading_dialog.ui rename to views/collection_loading_dialog.ui diff --git a/ui/downloading_dialog.ui b/views/downloading_dialog.ui similarity index 100% rename from ui/downloading_dialog.ui rename to views/downloading_dialog.ui diff --git a/ui/item_loading_dialog.ui b/views/item_loading_dialog.ui similarity index 100% rename from ui/item_loading_dialog.ui rename to views/item_loading_dialog.ui diff --git a/ui/query_dialog.ui b/views/query_dialog.ui similarity index 100% rename from ui/query_dialog.ui rename to views/query_dialog.ui diff --git a/ui/results_dialog.ui b/views/results_dialog.ui similarity index 100% rename from ui/results_dialog.ui rename to views/results_dialog.ui diff --git a/ui/select_bands_dialog.ui b/views/select_bands_dialog.ui similarity index 100% rename from ui/select_bands_dialog.ui rename to views/select_bands_dialog.ui From 0bd8d9f476cc2b24f56f33982a47c3adac2c3d39 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Wed, 19 Jun 2019 14:38:43 -0500 Subject: [PATCH 13/37] added config file --- controllers/item_loading_dialog.py | 2 +- controllers/query_dialog.py | 55 +++++++++++-------------- models/api.py | 65 +++++++++++++++++++++++------- models/catalog.py | 54 ------------------------- models/collection.py | 8 ++-- models/item.py | 12 +++--- models/search_result.py | 6 +-- stac_browser.py | 61 +++++++++++++--------------- threads/load_collections_thread.py | 16 ++++---- threads/load_items_thread.py | 20 ++++----- utils/config.py | 21 +++++++--- 11 files changed, 150 insertions(+), 170 deletions(-) delete mode 100644 models/catalog.py diff --git a/controllers/item_loading_dialog.py b/controllers/item_loading_dialog.py index fa05e94..756c68e 100644 --- a/controllers/item_loading_dialog.py +++ b/controllers/item_loading_dialog.py @@ -19,7 +19,7 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): self.setupUi(self) self.setFixedSize(self.size()) - self.loading_thread = LoadItemsThread(self.data['catalog_collections'], + self.loading_thread = LoadItemsThread(self.data['api_collections'], self.data['extent'], self.data['start_time'], self.data['end_time'], diff --git a/controllers/query_dialog.py b/controllers/query_dialog.py index 289de26..8397c03 100644 --- a/controllers/query_dialog.py +++ b/controllers/query_dialog.py @@ -23,7 +23,7 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): self.setupUi(self) self._extent_layers = None - self._catalog_tree_model = None + self._api_tree_model = None self.populate_time_periods() self.populate_extent_layers() @@ -48,18 +48,19 @@ def populate_extent_layers(self): self.extentLayer.addItem(layer.name()) def populate_collection_list(self): - self._catalog_tree_model = QStandardItemModel(self.treeView) - for catalog in self.catalogs: - catalog_node = QTreeWidgetItem(self.treeView) - catalog_node.setText(0, f'{catalog.title}') - catalog_node.setFlags(catalog_node.flags() | QtCore.Qt.ItemIsTristate | QtCore.Qt.ItemIsUserCheckable) - for collection in sorted(catalog.collections): + self._api_tree_model = QStandardItemModel(self.treeView) + for api in self.apis: + api_node = QTreeWidgetItem(self.treeView) + api_node.setText(0, f'{api.title}') + api_node.setFlags(api_node.flags() | QtCore.Qt.ItemIsTristate | QtCore.Qt.ItemIsUserCheckable) + api_node.setCheckState(0, QtCore.Qt.Unchecked) + for collection in sorted(api.collections): title = collection.title.replace("\n", " ") - collection_node = QTreeWidgetItem(catalog_node) + collection_node = QTreeWidgetItem(api_node) collection_node.setText(0, title) collection_node.setFlags(collection_node.flags() | QtCore.Qt.ItemIsUserCheckable) collection_node.setCheckState(0, QtCore.Qt.Unchecked) - + def validate(self): valid = True if self.extentLayer.currentIndex() < 0: @@ -72,37 +73,29 @@ def validate(self): return valid @property - def catalog_selections(self): - catalog_collections = [] + def api_selections(self): + api_collections = [] root = self.treeView.invisibleRootItem() for i in range(root.childCount()): - catalog_node = root.child(i) - catalog = self.catalogs[i] + api_node = root.child(i) + api = self.apis[i] selected_collections = [] - for j in range(catalog_node.childCount()): - collection_node = catalog_node.child(j) - collection = self.catalogs[i].collections[j] + for j in range(api_node.childCount()): + collection_node = api_node.child(j) + collection = api.collections[j] if collection_node.checkState(0) == QtCore.Qt.Checked: selected_collections.append(collection) - if len(selected_collections) > 0: - catalog_collections.append({ - 'catalog': catalog, + if api_node.checkState(0) == QtCore.Qt.Checked or api_node.checkState(0) == QtCore.Qt.PartiallyChecked: + api_collections.append({ + 'api': api, 'collections': selected_collections }) - return catalog_collections - - @property - def collections(self): - collections = [] - for catalog in sorted(self.data.get('catalogs', [])): - collections.extend(sorted(catalog.collections)) - - return collections + return api_collections @property - def catalogs(self): - return sorted(self.data.get('catalogs', [])) + def apis(self): + return sorted(self.data.get('apis', [])) @property def extent_layer(self): @@ -121,7 +114,7 @@ def on_search_clicked(self): if not valid: return - self.hooks['on_search'](self.catalog_selections, + self.hooks['on_search'](self.api_selections, self.extent_layer, self.time_period) diff --git a/models/api.py b/models/api.py index 53c3fff..3d7dc12 100644 --- a/models/api.py +++ b/models/api.py @@ -1,20 +1,22 @@ import re from urllib.parse import urlparse from .collection import Collection -from .catalog import Catalog +from .link import Link from .search_result import SearchResult from ..utils import network class API: - def __init__(self, href=None): - self._href = href - self._catalog = None + def __init__(self, json=None): + self._json = json + self._data = self._json.get('data', None) + self._collections = self._json.get('collections', None) - def load_catalog(self): - return Catalog(self, network.request(f'{self.href}/stac')) + def load(self): + self._data = network.request(f'{self.href}/stac') + self._collections = [self.load_collection(c) for c in self.collection_ids] - def load_collection(self, catalog, collection_id): - return Collection(catalog, network.request(f'{self.href}/collections/{collection_id}')) + def load_collection(self, collection_id): + return Collection(self, network.request(f'{self.href}/collections/{collection_id}')) def search_items(self, collections=[], bbox=[], start_time=None, end_time=None, page=1, limit=50, on_next_page=None): @@ -53,14 +55,49 @@ def collection_id_from_href(self, href): return m.groups()[0] + @property + def json(self): + return { + 'href': self.href, + 'data': self.data, + } + + @property + def title(self): + return self.data.get('title', None) + @property def href(self): - return self._href - + return self._json.get('href', None) + + @property + def data(self): + return self._data + @property - def catalog(self): - if self._catalog is None: - self._catalog = self.load_catalog() + def links(self): + return [Link(l) for l in self.data.get('links', [])] - return self._catalog + @property + def collection_ids(self): + collection_ids = [] + p = re.compile('\/collections\/(.*)') + + for link in self.links: + m = p.match(urlparse(link.href).path) + if m is None: + continue + + if m.groups() is None: + continue + collection_ids.append(m.groups()[0]) + + return collection_ids + + @property + def collections(self): + return self._collections + + def __lt__(self, other): + return self.title.lower() < other.title.lower() diff --git a/models/catalog.py b/models/catalog.py deleted file mode 100644 index 5ba4183..0000000 --- a/models/catalog.py +++ /dev/null @@ -1,54 +0,0 @@ -from .link import Link - -class Catalog: - def __init__(self, api=None, json={}): - self._api = api - self._json = json - self._collections = None - - @property - def api(self): - return self._api - - @property - def id(self): - return self._json.get('id', None) - - @property - def stac_version(self): - return self._json.get('stac_version', None) - - @property - def title(self): - return self._json.get('title', None) - - @property - def description(self): - return self._json.get('description', None) - - @property - def collections(self): - if self._collections is None: - self.load_collections() - - return self._collections - - @property - def links(self): - return [Link(l) for l in self._json.get('links', [])] - - @property - def api(self): - return self._api - - def load_collections(self): - self._collections = [] - for link in self.links: - collection_id = self.api.collection_id_from_href(link.href) - if collection_id is None: - continue - self._collections.append(self.api.load_collection(self, collection_id)) - - def __lt__(self, other): - return self.title.lower() < other.title.lower() - diff --git a/models/collection.py b/models/collection.py index 166a242..770b15f 100644 --- a/models/collection.py +++ b/models/collection.py @@ -1,8 +1,8 @@ from .link import Link class Collection: - def __init__(self, catalog=None, json={}): - self._catalog = catalog + def __init__(self, api=None, json={}): + self._api = api self._json = json @property @@ -59,8 +59,8 @@ def bands(self): return bands @property - def catalog(self): - return self._catalog + def api(self): + return self._pi def __lt__(self, other): return self.title.lower() < other.title.lower() diff --git a/models/item.py b/models/item.py index 97fe522..b094b3a 100644 --- a/models/item.py +++ b/models/item.py @@ -7,17 +7,17 @@ from ..utils import network class Item: - def __init__(self, catalog=None, json={}): - self._catalog = catalog + def __init__(self, api=None, json={}): + self._api = api self._json = json @property def hashed_id(self): - return hashlib.sha256(f'{self.catalog.api.href}/collections/{self.collection.id}/items/{self.id}'.encode('utf-8')).hexdigest() + return hashlib.sha256(f'{self.api.href}/collections/{self.collection.id}/items/{self.id}'.encode('utf-8')).hexdigest() @property - def catalog(self): - return self._catalog + def api(self): + return self._api @property def id(self): @@ -57,7 +57,7 @@ def collection(self): if collection_id is None: collection_id = self._json.get('collection', None) - for collection in self.catalog.collections: + for collection in self.api.collections: if collection.id == collection_id: return collection diff --git a/models/search_result.py b/models/search_result.py index eb43ab1..d9412d5 100644 --- a/models/search_result.py +++ b/models/search_result.py @@ -10,10 +10,6 @@ def __init__(self, api=None, json={}): def api(self): return self._api - @property - def catalog(self): - return self._api.catalog - @property def type(self): return self._json.get('type', None) @@ -24,7 +20,7 @@ def meta(self): @property def items(self): - return [Item(self.catalog, f) for f in self._json.get('features', [])] + return [Item(self.api, f) for f in self._json.get('features', [])] @property def links(self): diff --git a/stac_browser.py b/stac_browser.py index 4635be5..7f1a029 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -56,14 +56,14 @@ def __init__(self, iface): }, } - def on_search(self, catalog_collections, extent_layer, time_period): + def on_search(self, api_collections, extent_layer, time_period): (start_time, end_time) = time_period extent_rect = extent_layer.extent() extent = [extent_rect.xMinimum(), extent_rect.yMinimum(), extent_rect.xMaximum(), extent_rect.yMaximum()] self.windows['ITEM_LOADING']['data'] = { - 'catalog_collections': catalog_collections, + 'api_collections': api_collections, 'extent': extent, 'start_time': start_time, 'end_time': end_time @@ -85,9 +85,6 @@ def on_close(self): return self.reset_windows() - def on_popup_close(self): - return - def on_download(self, items, selected_bands, download_directory, stream): self.windows['DOWNLOADING']['data'] = { 'items': items, 'bands': selected_bands, 'download_directory': download_directory, 'stream': stream } self.current_window = 'DOWNLOADING' @@ -99,34 +96,8 @@ def downloading_finished(self): self.current_window = 'COLLECTION_LOADING' self.reset_windows() - def load_window(self): - window = self.windows.get(self.current_window, None) - - if window is None: - logging.error(f'Window {self.current_window} does not exist') - return - - if window['dialog'] is None: - window['dialog'] = window.get('class')(data=window.get('data'), - hooks=window.get('hooks'), - parent=self.iface.mainWindow(), - iface=self.iface) - window['dialog'].show() - else: - window['dialog'].raise_() - window['dialog'].show() - window['dialog'].activateWindow() - - def reset_windows(self): - for key, window in self.windows.items(): - if window['dialog'] is not None: - window['dialog'].close() - window['data'] = None - window['dialog'] = None - self.current_window = 'COLLECTION_LOADING' - def collection_load_finished(self, apis): - self.windows['QUERY']['data'] = { 'catalogs': [api.catalog for api in apis] } + self.windows['QUERY']['data'] = { 'apis': apis } self.current_window = 'QUERY' self.windows['COLLECTION_LOADING']['dialog'].close() self.load_window() @@ -179,6 +150,32 @@ def add_action(self, icon_path, text, callback, enabled_flag=True, return action + def load_window(self): + window = self.windows.get(self.current_window, None) + + if window is None: + logging.error(f'Window {self.current_window} does not exist') + return + + if window['dialog'] is None: + window['dialog'] = window.get('class')(data=window.get('data'), + hooks=window.get('hooks'), + parent=self.iface.mainWindow(), + iface=self.iface) + window['dialog'].show() + else: + window['dialog'].raise_() + window['dialog'].show() + window['dialog'].activateWindow() + + def reset_windows(self): + for key, window in self.windows.items(): + if window['dialog'] is not None: + window['dialog'].close() + window['data'] = None + window['dialog'] = None + self.current_window = 'COLLECTION_LOADING' + def initGui(self): icon_path = ':/plugins/stac_browser/assets/icon.png' self.add_action( diff --git a/threads/load_collections_thread.py b/threads/load_collections_thread.py index 57a9861..5c864cb 100644 --- a/threads/load_collections_thread.py +++ b/threads/load_collections_thread.py @@ -21,14 +21,16 @@ def __init__(self, api_list, on_progress=None, on_error=None, on_finished=None): def run(self): apis = [] - for i, api_url in enumerate(self.api_list): + for i, api in enumerate(self.api_list): progress = (float(i) / float(len(self.api_list))) - self.progress_signal.emit(progress, api_url) - api = API(api_url) - try: - api.catalog.load_collections() + self.progress_signal.emit(progress, api.href) + if api.data is None: + try: + api.load() + apis.append(api) + except URLError as e: + self.error_signal.emit(e, api) + else: apis.append(api) - except URLError as e: - self.error_signal.emit(e, api) self.finished_signal.emit(apis) diff --git a/threads/load_items_thread.py b/threads/load_items_thread.py index 6ec0aae..324e3f0 100644 --- a/threads/load_items_thread.py +++ b/threads/load_items_thread.py @@ -6,12 +6,12 @@ class LoadItemsThread(QThread): error_signal = pyqtSignal(Exception) finished_signal = pyqtSignal(list) - def __init__(self, catalog_collections, extent, start_time, end_time, + def __init__(self, api_collections, extent, start_time, end_time, on_progress=None, on_error=None, on_finished=None): QThread.__init__(self) self.current_page = 0 - self.catalog_collections = catalog_collections + self.api_collections = api_collections self.extent = extent self.start_time = start_time self.end_time = end_time @@ -27,16 +27,16 @@ def __init__(self, catalog_collections, extent, start_time, end_time, def run(self): try: all_items = [] - for catalog_collection in self.catalog_collections: - catalog = catalog_collection['catalog'] - collections = catalog_collection['collections'] + for api_collection in self.api_collections: + api = api_collection['api'] + collections = api_collection['collections'] self._current_collections = collections - items = catalog.api.search_items(collections, - self.extent, - self.start_time, - self.end_time, - on_next_page=self.on_next_page) + items = api.search_items(collections, + self.extent, + self.start_time, + self.end_time, + on_next_page=self.on_next_page) all_items.extend(items) self.finished_signal.emit(all_items) except URLError as e: diff --git a/utils/config.py b/utils/config.py index ea310a8..652caa6 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,9 +1,8 @@ import os import json +from ..models.api import API class Config: - STAC_APIS = ['https://stac.boundlessgeo.io', 'https://sat-api.developmentseed.org'] - def __init__(self): self._json = None self.load() @@ -18,7 +17,7 @@ def load(self): def save(self): config = { - 'apis': self.apis + 'apis': [api.json for api in self.apis] } with open(self.path, 'w') as f: f.write(json.dumps(config)) @@ -32,6 +31,16 @@ def apis(self): apis = self._json.get('apis', None) if apis is None: - apis = self.STAC_APIS - - return apis + apis = [ + { + "href": "https://stac.boundlessgeo.io", + }, + { + "href": "https://sat-api.developmentseed.org", + }, + { + "href": "https://stac.astraea.earth/api/v2", + } + ] + + return [API(api) for api in apis] From f5ab3e16c193057a4b81dff8df07547d682956e3 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Thu, 20 Jun 2019 12:56:41 -0500 Subject: [PATCH 14/37] Updated download selection to work with items not associated with a collection --- controllers/collection_loading_dialog.py | 7 + controllers/download_selection_dialog.py | 186 +++++++++++++++++++++++ controllers/downloading_controller.py | 64 ++++++++ controllers/downloading_dialog.py | 94 ------------ controllers/results_dialog.py | 12 +- controllers/select_bands_dialog.py | 94 ------------ models/api.py | 3 +- models/collection.py | 4 + models/item.py | 98 ++++++++---- stac_browser.py | 80 +++++++--- threads/download_items_thread.py | 30 ++-- threads/load_collections_thread.py | 11 +- utils/config.py | 29 +++- views/download_selection_dialog.ui | 172 +++++++++++++++++++++ views/select_bands_dialog.ui | 83 ---------- 15 files changed, 618 insertions(+), 349 deletions(-) create mode 100644 controllers/download_selection_dialog.py create mode 100644 controllers/downloading_controller.py delete mode 100644 controllers/downloading_dialog.py delete mode 100644 controllers/select_bands_dialog.py create mode 100644 views/download_selection_dialog.ui delete mode 100644 views/select_bands_dialog.ui diff --git a/controllers/collection_loading_dialog.py b/controllers/collection_loading_dialog.py index 0d494d7..0598633 100644 --- a/controllers/collection_loading_dialog.py +++ b/controllers/collection_loading_dialog.py @@ -1,3 +1,5 @@ +import time + from PyQt5 import uic, QtWidgets from ..utils.config import Config @@ -35,6 +37,11 @@ def on_error(self, e, api): error(self.iface, f'Failed to load {api.href}; {e.reason}') def on_loading_finished(self, apis): + config = Config() + config.apis = apis + config.last_update = time.time() + config.save() + self.progressBar.setValue(100) self.hooks['on_finished'](apis) diff --git a/controllers/download_selection_dialog.py b/controllers/download_selection_dialog.py new file mode 100644 index 0000000..0cbaf8e --- /dev/null +++ b/controllers/download_selection_dialog.py @@ -0,0 +1,186 @@ +from PyQt5 import uic +from PyQt5 import QtWidgets +from PyQt5 import QtCore +from PyQt5.QtGui import QStandardItemModel, QStandardItem + +from qgis.core import QgsProject, QgsMapLayer + +from ..utils import ui +from pprint import pprint + + +FORM_CLASS, _ = uic.loadUiType(ui.path('download_selection_dialog.ui')) + +class DownloadSelectionDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None, iface=None): + super(DownloadSelectionDialog, self).__init__(parent) + + self.data = data + self.hooks = hooks + self.iface = iface + + self.setupUi(self) + + self._current_item_index = 0 + self._downloads = [] + + self.populate_current_item() + + self.nextButton.clicked.connect(self.on_next_clicked) + self.cancelButton.clicked.connect(self.on_cancel_clicked) + + def populate_current_item(self): + if self._current_item_index + 1 == len(self.items): + self.nextButton.setText('Download') + else: + self.nextButton.setText('Next') + + + collection_label = 'N/A' + if self.current_item.collection is not None: + collection_label = self.current_item.collection.id + self.itemLabel.setText(self.current_item.id) + self.collectionLabel.setText(collection_label) + + self.assetListWidget.clear() + for asset in sorted(self.current_item.assets): + asset_node = QtWidgets.QListWidgetItem(self.assetListWidget) + asset_node.setText(f'{asset.title}') + asset_node.setFlags(asset_node.flags() | QtCore.Qt.ItemIsUserCheckable) + asset_node.setCheckState(QtCore.Qt.Unchecked) + + def add_current_item_to_downloads(self): + apply_to_all = (self.applyAllCheckbox.checkState() == QtCore.Qt.Checked) + add_to_layers = (self.addLayersCheckbox.checkState() == QtCore.Qt.Checked) + stream_cogs = (self.streamCheckbox.checkState() == QtCore.Qt.Checked) + + download_data = { + 'item': self.current_item, + 'options': { + 'add_to_layers': add_to_layers, + 'stream_cogs': stream_cogs, + 'assets': [a.key for a in self.selected_assets], + }, + } + + self.downloads.append(download_data) + + if not apply_to_all or self.current_item.collection is None: + return + + for i in range(self._current_item_index, len(self.items)): + if self.item_in_downloads(self.items[i]): + continue + + if self.items[i].collection is None: + continue + + if self.items[i].collection == self.current_item.collection: + download_data = { + 'item': self.items[i], + 'options': { + 'add_to_layers': add_to_layers, + 'stream_cogs': stream_cogs, + 'assets': [a.key for a in self.selected_assets], + }, + } + self.downloads.append(download_data) + + def item_in_downloads(self, item): + for d in self.downloads: + if d['item'] == item: + return True + + return False + + @property + def downloads(self): + return self._downloads + + @property + def selected_assets(self): + sorted_assets = sorted(self.current_item.assets) + assets = [] + for i in range(self.assetListWidget.count()): + asset_node = self.assetListWidget.item(i) + if asset_node.checkState() != QtCore.Qt.Checked: + continue + assets.append(sorted_assets[i]) + + return assets + + @property + def current_item(self): + if self._current_item_index >= len(self.items): + return None + + return self.items[self._current_item_index] + + @property + def items(self): + return sorted(self.data.get('items', [])) + + @property + def stream(self): + return self.cogCheckbox.checkState() == QtCore.Qt.Checked + + @property + def collections(self): + collections = [] + for item in self.items: + if item.collection not in collections: + collections.append(item.collection) + + return sorted(collections) + + @property + def selected_bands(self): + collection_band_list = [] + for i in range(len(self.collections)): + collection = self.collections[i] + collection_node = self._bands_tree_model.item(i) + bands = list(collection.bands.items()) + selected_bands = [] + for j in range(collection_node.rowCount()): + band_node = collection_node.child(j) + band_name, band_data = bands[j] + + if band_node.checkState() == QtCore.Qt.Checked: + selected_bands.append(band_name) + + if len(selected_bands) > 0: + collection_band_list.append({ + 'collection': collection, + 'bands': selected_bands + }) + + return collection_band_list + + @property + def next_item(self): + if self._current_item_index + 1 >= len(self.items): + return None + + return self.items[self._current_item_index + 1] + + def on_next_clicked(self): + self.add_current_item_to_downloads() + + self._current_item_index += 1 + while self.current_item is not None: + if not self.item_in_downloads(self.current_item): + break + self._current_item_index += 1 + + if self.current_item is None: + self.accept() + return + + self.populate_current_item() + + def on_cancel_clicked(self): + self.reject() + + def closeEvent(self, event): + if event.spontaneous(): + self.reject() diff --git a/controllers/downloading_controller.py b/controllers/downloading_controller.py new file mode 100644 index 0000000..1dde18c --- /dev/null +++ b/controllers/downloading_controller.py @@ -0,0 +1,64 @@ +import os + +from qgis.core import QgsRasterLayer, QgsProject, Qgis + +from qgis.PyQt.QtWidgets import QProgressBar +from qgis.PyQt.QtCore import * + +from ..utils import ui +from ..utils.logging import debug, info, warning, error +from ..threads.download_items_thread import DownloadItemsThread + + +class DownloadController: + def __init__(self, data={}, hooks={}, iface=None): + self.data = data + self.hooks = hooks + self.iface = iface + + self._progress_message_bar = None + + + self.loading_thread = DownloadItemsThread(self.downloads, + self.download_directory, + on_progress=self.on_progress_update, + on_error=self.on_error, + on_add_layer=self.on_add_layer, + on_finished=self.on_downloading_finished) + + self.loading_thread.start() + + @property + def downloads(self): + return self.data.get('downloads', []) + + @property + def download_directory(self): + return self.data.get('download_directory', None) + + def on_error(self, item, e): + error(self.iface, f'Failed to load {item.id}; {e.reason}') + + def on_add_layer(self, current_step, total_steps, item, download_directory): + self.on_progress_update(current_step, total_steps, 'ADDING_TO_LAYERS') + layer = QgsRasterLayer(os.path.join(download_directory, f'{item.id}.vrt'), item.id) + QgsProject.instance().addMapLayer(layer) + + def on_destroyed(self, event): + if not self.loading_thread.isFinished: + self.loading_thread.terminate() + + def on_progress_update(self, current_step, total_steps, state, data={}): + if self._progress_message_bar is None: + self._progress_message_bar = self.iface.messageBar().createMessage('Downloading Items...') + self._progress_message_bar.destroyed.connect(self.on_destroyed) + self._progress = QProgressBar() + self._progress.setMaximum(total_steps) + self._progress.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._progress_message_bar.layout().addWidget(self._progress) + self.iface.messageBar().pushWidget(self._progress_message_bar, Qgis.Info) + + self._progress.setValue(current_step-1) + + def on_downloading_finished(self): + self.iface.messageBar().clearWidgets() diff --git a/controllers/downloading_dialog.py b/controllers/downloading_dialog.py deleted file mode 100644 index ed58e01..0000000 --- a/controllers/downloading_dialog.py +++ /dev/null @@ -1,94 +0,0 @@ -import os - -from PyQt5 import uic, QtWidgets - -from qgis.core import ( - QgsRasterLayer, - QgsProject -) - -from ..utils import ui -from ..utils.logging import debug, info, warning, error -from ..threads.download_items_thread import DownloadItemsThread - - -FORM_CLASS, _ = uic.loadUiType(ui.path('downloading_dialog.ui')) - -class DownloadingDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None, iface=None): - super(DownloadingDialog, self).__init__(parent) - - self.data = data - self.hooks = hooks - self.iface = iface - - self.setupUi(self) - self.setFixedSize(self.size()) - - self.loading_thread = DownloadItemsThread(self.items, - self.bands, - self.download_directory, - self.stream, - on_progress=self.on_progress_update, - on_error=self.on_error, - on_add_layer=self.on_add_layer, - on_finished=self.on_downloading_finished) - - self.loading_thread.start() - - @property - def items(self): - return self.data.get('items', []) - - @property - def bands(self): - return self.data.get('bands', []) - - @property - def download_directory(self): - return self.data.get('download_directory', None) - - @property - def stream(self): - return self.data.get('stream', False) - - def on_error(self, item, e): - error(self.iface, f'Failed to load {item.id}; {e.reason}') - - def on_add_layer(self, current_item, total_items, item, download_directory): - self.on_progress_update(current_item, total_items, 'ADDING_TO_LAYERS') - layer = QgsRasterLayer(os.path.join(download_directory, f'{item.id}.vrt'), item.id) - QgsProject.instance().addMapLayer(layer) - - def on_progress_update(self, current_item, total_items, state, data={}): - self.totalLabel.setText(f'Item {current_item+1} of {total_items}') - progress = int((current_item / total_items) * 100) - self.totalProgress.setValue(progress) - - total_bands = len(data.get('bands', [])) - total_steps = total_bands + 2 - if state == 'DOWNLOADING_BAND': - current_band = data.get('band', '???') - self.itemLabel.setText(f'Downloading Band {current_band}') - current_step = data.get('bands', []).index(current_band) + 1 - elif state == 'BUILDING_VRT': - self.itemLabel.setText('Building Virtual Raster') - current_step = total_bands + 1 - elif state == 'ADDING_TO_LAYERS': - self.itemLabel.setText('Adding to Layers') - current_step = total_bands + 2 - - progress = int(((current_step - 1) / total_steps) * 100) - self.itemProgress.setValue(progress) - - def on_downloading_finished(self): - self.totalProgress.setValue(100) - self.itemProgress.setValue(100) - self.hooks['on_finished']() - - def closeEvent(self, event): - if event.spontaneous(): - self.loading_thread.terminate() - self.hooks['on_close']() - - diff --git a/controllers/results_dialog.py b/controllers/results_dialog.py index 1467cad..77bc919 100644 --- a/controllers/results_dialog.py +++ b/controllers/results_dialog.py @@ -5,6 +5,7 @@ from ..models.item import Item from ..utils import ui +from ..utils.config import Config from ..threads.load_preview_thread import LoadPreviewThread @@ -22,8 +23,10 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): self._item_list_model = None self._selected_item = None + self._config = Config() self.populate_item_list() + self.populate_download_directory() self.list.activated.connect(self.on_list_clicked) self.selectButton.clicked.connect(self.on_select_all_clicked) @@ -42,6 +45,9 @@ def populate_item_list(self): self.list.setModel(self._item_list_model) + def populate_download_directory(self): + self.downloadDirectory.setText(self._config.download_directory) + def populate_item_details(self, item): property_keys = sorted(list(item.properties.keys())) @@ -71,7 +77,7 @@ def download_directory(self): return self.downloadDirectory.text() def on_download_clicked(self): - self.hooks['select_bands'](self.selected_items, self.download_directory) + self.hooks['select_downloads'](self.selected_items, self.download_directory) def on_download_path_clicked(self): directory = QFileDialog.getExistingDirectory(self, @@ -79,7 +85,9 @@ def on_download_path_clicked(self): "", QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks) if directory: - self.downloadDirectory.setText(directory) + self._config.download_directory = directory + self._config.save() + self.populate_download_directory() def on_select_all_clicked(self): for i in range(self._item_list_model.rowCount()): diff --git a/controllers/select_bands_dialog.py b/controllers/select_bands_dialog.py deleted file mode 100644 index 26f5af9..0000000 --- a/controllers/select_bands_dialog.py +++ /dev/null @@ -1,94 +0,0 @@ -from PyQt5 import uic -from PyQt5 import QtWidgets -from PyQt5 import QtCore -from PyQt5.QtGui import QStandardItemModel, QStandardItem - -from qgis.core import QgsProject, QgsMapLayer - -from ..utils import ui - - -FORM_CLASS, _ = uic.loadUiType(ui.path('select_bands_dialog.ui')) - -class SelectBandsDialog(QtWidgets.QDialog, FORM_CLASS): - def __init__(self, data={}, hooks={}, parent=None, iface=None): - super(SelectBandsDialog, self).__init__(parent) - - self.data = data - self.hooks = hooks - self.iface = iface - - self.setupUi(self) - - self._bands_tree_model = None - - self.populate_bands_list() - - self.downloadButton.clicked.connect(self.on_download_clicked) - self.cancelButton.clicked.connect(self.on_cancel_clicked) - - def populate_bands_list(self): - self._bands_tree_model = QStandardItemModel(self.treeView) - for collection in self.collections: - collection_node = QStandardItem(f'{collection.title}') - for band_name, band_data in collection.bands.items(): - if band_data.get('common_name', None) is not None: - band_node = QStandardItem(f'{band_name} ({band_data.get("common_name")})') - else: - band_node = QStandardItem(band_name) - band_node.setCheckable(True) - collection_node.appendRow(band_node) - self._bands_tree_model.appendRow(collection_node) - - self.treeView.setModel(self._bands_tree_model) - self.treeView.expandAll() - - @property - def items(self): - return self.data.get('items', []) - - @property - def stream(self): - return self.cogCheckbox.checkState() == QtCore.Qt.Checked - - @property - def collections(self): - collections = [] - for item in self.items: - if item.collection not in collections: - collections.append(item.collection) - - return sorted(collections) - - @property - def selected_bands(self): - collection_band_list = [] - for i in range(len(self.collections)): - collection = self.collections[i] - collection_node = self._bands_tree_model.item(i) - bands = list(collection.bands.items()) - selected_bands = [] - for j in range(collection_node.rowCount()): - band_node = collection_node.child(j) - band_name, band_data = bands[j] - - if band_node.checkState() == QtCore.Qt.Checked: - selected_bands.append(band_name) - - if len(selected_bands) > 0: - collection_band_list.append({ - 'collection': collection, - 'bands': selected_bands - }) - - return collection_band_list - - def on_download_clicked(self): - self.accept() - - def on_cancel_clicked(self): - self.reject() - - def closeEvent(self, event): - if event.spontaneous(): - self.reject() diff --git a/models/api.py b/models/api.py index 3d7dc12..a6e4430 100644 --- a/models/api.py +++ b/models/api.py @@ -9,7 +9,7 @@ class API: def __init__(self, json=None): self._json = json self._data = self._json.get('data', None) - self._collections = self._json.get('collections', None) + self._collections = [Collection(self, c) for c in self._json.get('collections', [])] def load(self): self._data = network.request(f'{self.href}/stac') @@ -60,6 +60,7 @@ def json(self): return { 'href': self.href, 'data': self.data, + 'collections': [c.json for c in self.collections], } @property diff --git a/models/collection.py b/models/collection.py index 770b15f..6a7a866 100644 --- a/models/collection.py +++ b/models/collection.py @@ -5,6 +5,10 @@ def __init__(self, api=None, json={}): self._api = api self._json = json + @property + def json(self): + return self._json + @property def stac_version(self): return self._json.get('stac_version', None) diff --git a/models/item.py b/models/item.py index b094b3a..a181b95 100644 --- a/models/item.py +++ b/models/item.py @@ -45,9 +45,9 @@ def links(self): @property def assets(self): - assets = {} + assets = [] for key, d in self._json.get('assets', {}).items(): - assets[key] = Asset(d) + assets.append(Asset(key, d)) return assets @@ -65,7 +65,10 @@ def collection(self): @property def thumbnail(self): - return self.assets.get('thumbnail', None) + for asset in self.assets: + if asset.key == 'thumbnail': + return asset + return None @property def thumbnail_url(self): @@ -103,43 +106,71 @@ def bands(self): def thumbnail_downloaded(self): return self._thumbnail is not None - def download(self, bands, download_directory, stream=False, on_update=None): - item_download_directory = os.path.join(download_directory, self.id) - if not os.path.exists(item_download_directory): - os.makedirs(item_download_directory) + def download_steps(self, options): + steps = 0 + + for asset_key in options.get('assets', []): + for asset in self.assets: + if asset.key != asset_key: + continue - band_filenames = [] - for band in bands: - asset = self.assets.get(band, None) + if options.get('stream_cogs', False) and asset.cog is not None: + continue - if asset is None: - print('!!! ASSET NOT FOUND !!!') - continue + steps += 1 + + if options.get('add_to_layers', False): + steps += 1 + return steps - if stream and asset.cog is not None: - band_filenames.append(asset.cog) - continue - + def download(self, options, download_directory, on_update=None): + item_download_directory = os.path.join(download_directory, self.id) + if not os.path.exists(item_download_directory): + os.makedirs(item_download_directory) + + raster_filenames = [] + + for asset_key in options.get('assets', []): + for asset in self.assets: + if asset.key != asset_key: + continue + + if options.get('stream_cogs', False) and asset.cog is not None: + raster_filenames.append(asset.cog) + continue + + if on_update is not None: + on_update('DOWNLOADING_ASSET', data={}) + + temp_filename = os.path.join(item_download_directory, asset.href.split('/')[-1]) + if asset.is_raster: + raster_filenames.append(temp_filename) + network.download(asset.href, temp_filename) + + if options.get('add_to_layers', False): if on_update is not None: - on_update('DOWNLOADING_BAND', data={'band': band, 'bands': bands}) - - temp_filename = os.path.join(item_download_directory, asset.href.split('/')[-1]) - band_filenames.append(temp_filename) - network.download(asset.href, temp_filename) - - if on_update is not None: - on_update('BUILDING_VRT', data={'bands': bands}) - arguments = ['gdalbuildvrt', '-separate', os.path.join(download_directory, f'{self.id}.vrt')] - arguments.extend(band_filenames) - subprocess.run(arguments) + on_update('BUILDING_VRT', data={}) + + arguments = ['gdalbuildvrt', '-separate', os.path.join(download_directory, f'{self.id}.vrt')] + arguments.extend(raster_filenames) + subprocess.run(arguments) def __lt__(self, other): return self.id < other.id class Asset: - def __init__(self, json={}): + def __init__(self, key, json={}): + self._key = key self._json = json + @property + def is_raster(self): + return (self._json.get('eo:name', None) is not None) + + @property + def key(self): + return self._key + @property def cog(self): if self.type in ['image/x.geotiff', 'image/vnd.stac.geotiff']: @@ -158,3 +189,12 @@ def title(self): @property def type(self): return self._json.get('type', None) + + def __lt__(self, other): + if self._json.get('eo:name', None) is None and other._json.get('eo:name', None) is not None: + return True + + if self._json.get('eo:name', None) is not None and other._json.get('eo:name', None) is not None: + return self._json.get('eo:name').lower() < other._json.get('eo:name').lower() + + return self.title.lower() < other.title.lower() diff --git a/stac_browser.py b/stac_browser.py index 7f1a029..23fc963 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -1,3 +1,6 @@ +import time +import os.path + from PyQt5.QtCore import QSettings, QCoreApplication from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QAction, QApplication @@ -7,10 +10,11 @@ from .controllers.query_dialog import QueryDialog from .controllers.item_loading_dialog import ItemLoadingDialog from .controllers.results_dialog import ResultsDialog -from .controllers.downloading_dialog import DownloadingDialog -from .controllers.select_bands_dialog import SelectBandsDialog +from .controllers.downloading_controller import DownloadController +from .controllers.download_selection_dialog import DownloadSelectionDialog +from .utils.config import Config from .utils.logging import debug, info, warning, error -import os.path + class STACBrowser: def __init__(self, iface): @@ -26,31 +30,40 @@ def __init__(self, iface): self.windows = { 'COLLECTION_LOADING': { 'class': CollectionLoadingDialog, - 'hooks': {'on_finished': self.collection_load_finished, 'on_close': self.on_close}, + 'hooks': { + 'on_finished': self.collection_load_finished, + 'on_close': self.on_close + }, 'data': None, 'dialog': None }, 'QUERY': { 'class': QueryDialog, - 'hooks': {'on_close': self.on_close, 'on_search': self.on_search}, + 'hooks': { + 'on_close': self.on_close, + 'on_search': self.on_search + }, 'data': None, 'dialog': None }, 'ITEM_LOADING': { 'class': ItemLoadingDialog, - 'hooks': {'on_close': self.on_close, 'on_finished': self.item_load_finished, 'on_error': self.results_error}, + 'hooks': { + 'on_close': self.on_close, + 'on_finished': self.item_load_finished, + 'on_error': self.results_error + }, 'data': None, 'dialog': None }, 'RESULTS': { 'class': ResultsDialog, - 'hooks': {'on_close': self.on_close, 'on_back': self.on_back, 'on_download': self.on_download, 'select_bands': self.select_bands}, - 'data': None, - 'dialog': None - }, - 'DOWNLOADING': { - 'class': DownloadingDialog, - 'hooks': {'on_close': self.on_close, 'on_finished': self.downloading_finished}, + 'hooks': { + 'on_close': self.on_close, + 'on_back': self.on_back, + 'on_download': self.on_download, + 'select_downloads': self.select_downloads + }, 'data': None, 'dialog': None }, @@ -60,7 +73,12 @@ def on_search(self, api_collections, extent_layer, time_period): (start_time, end_time) = time_period extent_rect = extent_layer.extent() - extent = [extent_rect.xMinimum(), extent_rect.yMinimum(), extent_rect.xMaximum(), extent_rect.yMaximum()] + extent = [ + extent_rect.xMinimum(), + extent_rect.yMinimum(), + extent_rect.xMaximum(), + extent_rect.yMaximum() + ] self.windows['ITEM_LOADING']['data'] = { 'api_collections': api_collections, @@ -85,11 +103,14 @@ def on_close(self): return self.reset_windows() - def on_download(self, items, selected_bands, download_directory, stream): - self.windows['DOWNLOADING']['data'] = { 'items': items, 'bands': selected_bands, 'download_directory': download_directory, 'stream': stream } - self.current_window = 'DOWNLOADING' - self.windows['RESULTS']['dialog'].close() - self.load_window() + def on_download(self, download_items, download_directory): + self._download_controller = DownloadController( + data = { + 'downloads': download_items, + 'download_directory': download_directory, + }, + hooks = {}, iface=self.iface) + self.reset_windows() def downloading_finished(self): self.windows['DOWNLOADING']['dialog'].close() @@ -117,14 +138,17 @@ def item_load_finished(self, items): self.windows['ITEM_LOADING']['dialog'] = None self.load_window() - def select_bands(self, items, download_directory): - select_bands = SelectBandsDialog(data={'items': items}, hooks={'on_close': self.on_close}, parent=self.windows['RESULTS']['dialog']) - result = select_bands.exec_() - + def select_downloads(self, items, download_directory): + dialog = DownloadSelectionDialog(data={'items': items}, + hooks={'on_close': self.on_close}, + parent=self.windows['RESULTS']['dialog']) + + result = dialog.exec_() + if not result: return - - self.on_download(items, select_bands.selected_bands, download_directory, select_bands.stream) + + self.on_download(dialog.downloads, download_directory) def add_action(self, icon_path, text, callback, enabled_flag=True, add_to_menu=True, add_to_toolbar=True, status_tip=None, @@ -151,6 +175,12 @@ def add_action(self, icon_path, text, callback, enabled_flag=True, return action def load_window(self): + if self.current_window == 'COLLECTION_LOADING': + config = Config() + if config.last_update is not None and time.time() - config.last_update < config.api_update_interval: + self.current_window = 'QUERY' + self.windows['QUERY']['data'] = { 'apis': config.apis } + window = self.windows.get(self.current_window, None) if window is None: diff --git a/threads/download_items_thread.py b/threads/download_items_thread.py index 7c84dcb..42c0e87 100644 --- a/threads/download_items_thread.py +++ b/threads/download_items_thread.py @@ -8,13 +8,11 @@ class DownloadItemsThread(QThread): add_layer_signal = pyqtSignal(int, int, Item, str) finished_signal = pyqtSignal() - def __init__(self, items, bands, download_directory, stream, on_progress=None, on_error=None, on_add_layer=None, on_finished=None): + def __init__(self, downloads, download_directory, on_progress=None, on_error=None, on_add_layer=None, on_finished=None): QThread.__init__(self) - self.items = items - self.bands = bands + self.downloads = downloads self.download_directory = download_directory - self.stream = stream self.on_progress=on_progress self.on_error = on_error self.on_add_layer = on_add_layer @@ -22,24 +20,30 @@ def __init__(self, items, bands, download_directory, stream, on_progress=None, o self._current_item = None + self._current_step = 0 + self._total_steps = 0 + for download in self.downloads: + item = download['item'] + options = download['options'] + self._total_steps += item.download_steps(options) + self.progress_signal.connect(self.on_progress) self.error_signal.connect(self.on_error) self.add_layer_signal.connect(self.on_add_layer) self.finished_signal.connect(self.on_finished) def run(self): - for i, item in enumerate(self.items): - self._current_item = i - bands = [] - for collection_band in self.bands: - if collection_band['collection'] == item.collection: - bands = collection_band['bands'] + for download in self.downloads: + item = download['item'] + options = download['options'] try: - item.download(bands, self.download_directory, self.stream, on_update=self.on_update) - self.add_layer_signal.emit(i, len(self.items), item, self.download_directory) + item.download(options, self.download_directory, on_update=self.on_update) + if options.get('add_to_layers', False): + self.add_layer_signal.emit(self._current_step, self._total_steps, item, self.download_directory) except URLError as e: self.error_signal.emit(item, e) self.finished_signal.emit() def on_update(self, state, data={}): - self.progress_signal.emit(self._current_item, len(self.items), state, data) + self._current_step += 1 + self.progress_signal.emit(self._current_step, self._total_steps, state, data) diff --git a/threads/load_collections_thread.py b/threads/load_collections_thread.py index 5c864cb..b049e3c 100644 --- a/threads/load_collections_thread.py +++ b/threads/load_collections_thread.py @@ -24,13 +24,10 @@ def run(self): for i, api in enumerate(self.api_list): progress = (float(i) / float(len(self.api_list))) self.progress_signal.emit(progress, api.href) - if api.data is None: - try: - api.load() - apis.append(api) - except URLError as e: - self.error_signal.emit(e, api) - else: + try: + api.load() apis.append(api) + except URLError as e: + self.error_signal.emit(e, api) self.finished_signal.emit(apis) diff --git a/utils/config.py b/utils/config.py index 652caa6..6705350 100644 --- a/utils/config.py +++ b/utils/config.py @@ -17,7 +17,10 @@ def load(self): def save(self): config = { - 'apis': [api.json for api in self.apis] + 'apis': [api.json for api in self.apis], + 'download_directory': self.download_directory, + 'last_update': self.last_update, + 'api_update_interval': self.api_update_interval } with open(self.path, 'w') as f: f.write(json.dumps(config)) @@ -44,3 +47,27 @@ def apis(self): ] return [API(api) for api in apis] + + @apis.setter + def apis(self, apis): + self._json['apis'] = [api.json for api in apis] + + @property + def last_update(self): + return self._json.get('last_update', None) + + @property + def api_update_interval(self): + return self._json.get('api_update_interval', 60*60*24) + + @last_update.setter + def last_update(self, value): + self._json['last_update'] = value + + @property + def download_directory(self): + return self._json.get('download_directory', '') + + @download_directory.setter + def download_directory(self, value): + self._json['download_directory'] = value diff --git a/views/download_selection_dialog.ui b/views/download_selection_dialog.ui new file mode 100644 index 0000000..3b83562 --- /dev/null +++ b/views/download_selection_dialog.ui @@ -0,0 +1,172 @@ + + + STACBrowserDialogBase + + + + 0 + 0 + 611 + 324 + + + + STAC Browser + + + + + + + + + + QLayout::SetMaximumSize + + + + + + 175 + 0 + + + + + 175 + 16777215 + + + + TextLabel + + + + + + + Item ID + + + + + + + + + + + + + + Add To Layers + + + + + + + + + + + + + + Stream COGs + + + + + + + Collection ID + + + + + + + + 175 + 0 + + + + + 175 + 16777215 + + + + TextLabel + + + + + + + + + + + Select Assets to Download + + + + + + + true + + + + + + + + + + + + + Apply to all items of the same collection + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + Next + + + + + + + + + + + + diff --git a/views/select_bands_dialog.ui b/views/select_bands_dialog.ui deleted file mode 100644 index 0f88deb..0000000 --- a/views/select_bands_dialog.ui +++ /dev/null @@ -1,83 +0,0 @@ - - - STACBrowserDialogBase - - - - 0 - 0 - 287 - 324 - - - - STAC Browser - - - - - - - - Select Bands to Download - - - - - - - true - - - -1 - - - false - - - - - - - Stream Cloud Optimized GeoTIFFs - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Cancel - - - - - - - Download - - - - - - - - - - - - From 5c19de9834a33664f09d69e5cb3bec92f7011bc7 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Thu, 20 Jun 2019 13:37:17 -0500 Subject: [PATCH 15/37] Updated downloading progress text --- controllers/downloading_controller.py | 6 ++++-- controllers/item_loading_dialog.py | 4 ++-- models/api.py | 2 +- models/item.py | 4 ++-- threads/download_items_thread.py | 11 ++++++----- threads/load_items_thread.py | 8 +++++--- views/item_loading_dialog.ui | 2 +- 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/controllers/downloading_controller.py b/controllers/downloading_controller.py index 1dde18c..43040c7 100644 --- a/controllers/downloading_controller.py +++ b/controllers/downloading_controller.py @@ -48,15 +48,17 @@ def on_destroyed(self, event): if not self.loading_thread.isFinished: self.loading_thread.terminate() - def on_progress_update(self, current_step, total_steps, state, data={}): + def on_progress_update(self, current_step, total_steps, status): if self._progress_message_bar is None: - self._progress_message_bar = self.iface.messageBar().createMessage('Downloading Items...') + self._progress_message_bar = self.iface.messageBar().createMessage(status) self._progress_message_bar.destroyed.connect(self.on_destroyed) self._progress = QProgressBar() self._progress.setMaximum(total_steps) self._progress.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self._progress_message_bar.layout().addWidget(self._progress) self.iface.messageBar().pushWidget(self._progress_message_bar, Qgis.Info) + else: + self._progress_message_bar.setText(status) self._progress.setValue(current_step-1) diff --git a/controllers/item_loading_dialog.py b/controllers/item_loading_dialog.py index 756c68e..85d1743 100644 --- a/controllers/item_loading_dialog.py +++ b/controllers/item_loading_dialog.py @@ -29,9 +29,9 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): self.loading_thread.start() - def on_progress(self, collections, current_page): + def on_progress(self, api, collections, current_page): collection_label = ', '.join([c.title for c in collections]) - self.loadingLabel.setText(f'Searching {collection_label}\nPage {current_page}...') + self.loadingLabel.setText(f'Searching {api.title}\nCollections: [{collection_label}]\nPage {current_page}...') def on_error(self, e): error(self.iface, f'Network Error: {e.reason}') diff --git a/models/api.py b/models/api.py index a6e4430..6f1c54d 100644 --- a/models/api.py +++ b/models/api.py @@ -21,7 +21,7 @@ def load_collection(self, collection_id): def search_items(self, collections=[], bbox=[], start_time=None, end_time=None, page=1, limit=50, on_next_page=None): if on_next_page is not None: - on_next_page() + on_next_page(self) if end_time is None: time = start_time.strftime('%Y-%m-%dT%H:%M:%SZ') diff --git a/models/item.py b/models/item.py index a181b95..683b87f 100644 --- a/models/item.py +++ b/models/item.py @@ -140,7 +140,7 @@ def download(self, options, download_directory, on_update=None): continue if on_update is not None: - on_update('DOWNLOADING_ASSET', data={}) + on_update(f'Downloading {asset.href}') temp_filename = os.path.join(item_download_directory, asset.href.split('/')[-1]) if asset.is_raster: @@ -149,7 +149,7 @@ def download(self, options, download_directory, on_update=None): if options.get('add_to_layers', False): if on_update is not None: - on_update('BUILDING_VRT', data={}) + on_update(f'Building Virtual Raster...') arguments = ['gdalbuildvrt', '-separate', os.path.join(download_directory, f'{self.id}.vrt')] arguments.extend(raster_filenames) diff --git a/threads/download_items_thread.py b/threads/download_items_thread.py index 42c0e87..4dc5909 100644 --- a/threads/download_items_thread.py +++ b/threads/download_items_thread.py @@ -3,7 +3,7 @@ from ..models.item import Item class DownloadItemsThread(QThread): - progress_signal = pyqtSignal(int, int, str, dict) + progress_signal = pyqtSignal(int, int, str) error_signal = pyqtSignal(Item, Exception) add_layer_signal = pyqtSignal(int, int, Item, str) finished_signal = pyqtSignal() @@ -18,7 +18,7 @@ def __init__(self, downloads, download_directory, on_progress=None, on_error=Non self.on_add_layer = on_add_layer self.on_finished=on_finished - self._current_item = None + self._current_item = 0 self._current_step = 0 self._total_steps = 0 @@ -33,7 +33,8 @@ def __init__(self, downloads, download_directory, on_progress=None, on_error=Non self.finished_signal.connect(self.on_finished) def run(self): - for download in self.downloads: + for i, download in enumerate(self.downloads): + self._current_item = i item = download['item'] options = download['options'] try: @@ -44,6 +45,6 @@ def run(self): self.error_signal.emit(item, e) self.finished_signal.emit() - def on_update(self, state, data={}): + def on_update(self, status): self._current_step += 1 - self.progress_signal.emit(self._current_step, self._total_steps, state, data) + self.progress_signal.emit(self._current_step, self._total_steps, f'[{self._current_item + 1}/{len(self.downloads)}] {status}') diff --git a/threads/load_items_thread.py b/threads/load_items_thread.py index 324e3f0..67d58b2 100644 --- a/threads/load_items_thread.py +++ b/threads/load_items_thread.py @@ -1,8 +1,9 @@ from PyQt5.QtCore import QThread, pyqtSignal from urllib.error import URLError +from ..models.api import API class LoadItemsThread(QThread): - progress_signal = pyqtSignal(list, int) + progress_signal = pyqtSignal(API, list, int) error_signal = pyqtSignal(Exception) finished_signal = pyqtSignal(list) @@ -28,6 +29,7 @@ def run(self): try: all_items = [] for api_collection in self.api_collections: + self.current_page = 0 api = api_collection['api'] collections = api_collection['collections'] self._current_collections = collections @@ -42,6 +44,6 @@ def run(self): except URLError as e: self.error_signal.emit(e) - def on_next_page(self): + def on_next_page(self, api): self.current_page += 1 - self.progress_signal.emit(self._current_collections, self.current_page) + self.progress_signal.emit(api, self._current_collections, self.current_page) diff --git a/views/item_loading_dialog.ui b/views/item_loading_dialog.ui index 74bd76d..a308fa0 100644 --- a/views/item_loading_dialog.ui +++ b/views/item_loading_dialog.ui @@ -7,7 +7,7 @@ 0 0 345 - 68 + 81 From fc5730a8ec857b81b23c0e8a205816af9a777797 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Thu, 20 Jun 2019 13:49:51 -0500 Subject: [PATCH 16/37] Fixed sorted in download selection --- controllers/download_selection_dialog.py | 2 +- models/item.py | 53 +++++++++++++++--------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/controllers/download_selection_dialog.py b/controllers/download_selection_dialog.py index 0cbaf8e..ab8587f 100644 --- a/controllers/download_selection_dialog.py +++ b/controllers/download_selection_dialog.py @@ -45,7 +45,7 @@ def populate_current_item(self): self.assetListWidget.clear() for asset in sorted(self.current_item.assets): asset_node = QtWidgets.QListWidgetItem(self.assetListWidget) - asset_node.setText(f'{asset.title}') + asset_node.setText(f'{asset.pretty_title}') asset_node.setFlags(asset_node.flags() | QtCore.Qt.ItemIsUserCheckable) asset_node.setCheckState(QtCore.Qt.Unchecked) diff --git a/models/item.py b/models/item.py index 683b87f..740cc83 100644 --- a/models/item.py +++ b/models/item.py @@ -47,7 +47,7 @@ def links(self): def assets(self): assets = [] for key, d in self._json.get('assets', {}).items(): - assets.append(Asset(key, d)) + assets.append(Asset(key, d, item=self)) return assets @@ -89,20 +89,6 @@ def temp_dir(self): def thumbnail_path(self): return os.path.join(self.temp_dir, 'thumbnail.jpg') - @property - def vrt(self): - return os.path.join(self.temp_dir, 'asset.vrt') - - @property - def bands(self): - bands = [] - for k, band in self.collection.bands.items(): - asset = self.assets.get(k, None) - if asset is not None: - bands.append(asset) - - return bands - def thumbnail_downloaded(self): return self._thumbnail is not None @@ -159,9 +145,10 @@ def __lt__(self, other): return self.id < other.id class Asset: - def __init__(self, key, json={}): + def __init__(self, key, json={}, item=None): self._key = key self._json = json + self._item = item @property def is_raster(self): @@ -186,15 +173,41 @@ def href(self): def title(self): return self._json.get('title', None) + @property + def pretty_title(self): + if self.title is not None: + return self.title + + return self.key + @property def type(self): return self._json.get('type', None) + @property + def band(self): + if self._item.collection is None: + return -1 + + collection_bands = self._item.collection.properties.get('eo:bands', []) + + for i, c in enumerate(collection_bands): + if c.get('name', None) == self.key: + return i + + return -1 + def __lt__(self, other): - if self._json.get('eo:name', None) is None and other._json.get('eo:name', None) is not None: - return True + if self.band != -1 and other.band != -1: + return self.band < other.band + + if self.band == -1 and other.band != -1: + return False - if self._json.get('eo:name', None) is not None and other._json.get('eo:name', None) is not None: - return self._json.get('eo:name').lower() < other._json.get('eo:name').lower() + if self.band != -1 and other.band == -1: + return True + + if self.title is None or other.title is None: + return self.key.lower() < other.key.lower() return self.title.lower() < other.title.lower() From 612cc3d365f69587ab33d47d277aa8cf369998db Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Thu, 20 Jun 2019 14:09:27 -0500 Subject: [PATCH 17/37] Create LICENSE --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 7e46641318976a82b343a8b7a52fe87b529796fa Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Fri, 21 Jun 2019 09:34:26 -0500 Subject: [PATCH 18/37] Fixed ssl auth error --- utils/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/network.py b/utils/network.py index 4fbbcda..aae951c 100644 --- a/utils/network.py +++ b/utils/network.py @@ -8,7 +8,7 @@ def request(url, data=None): if os.environ.get('STAC_DEBUG', False): context = ssl._create_unverified_context() else: - context = None + context = ssl.SSLContext() r = urllib.request.Request(url) if data is not None: From 4b4b7da93372e1ed3a3b7ee05e7f0b34970d774d Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Fri, 21 Jun 2019 09:41:00 -0500 Subject: [PATCH 19/37] Fixed ssl verification on downloading files --- utils/network.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/utils/network.py b/utils/network.py index aae951c..6694feb 100644 --- a/utils/network.py +++ b/utils/network.py @@ -4,24 +4,23 @@ import json import os -def request(url, data=None): +def ssl_context(): if os.environ.get('STAC_DEBUG', False): - context = ssl._create_unverified_context() - else: - context = ssl.SSLContext() + return ssl._create_unverified_context() + return ssl.SSLContext() +def request(url, data=None): r = urllib.request.Request(url) if data is not None: body_bytes = json.dumps(data).encode('utf-8') r.add_header('Content-Type', 'application/json; charset=utf-8') r.add_header('Content-Length', len(body_bytes)) - r = urllib.request.urlopen(r, body_bytes, context=context, timeout=5) + r = urllib.request.urlopen(r, body_bytes, context=ssl_context(), timeout=5) else: - r = urllib.request.urlopen(r, context=context, timeout=5) + r = urllib.request.urlopen(r, context=ssl_context(), timeout=5) return json.loads(r.read()) def download(url, path): - context = ssl._create_unverified_context() - with urllib.request.urlopen(url, context=context, timeout=5) as response, open(path, 'wb') as f: + with urllib.request.urlopen(url, context=ssl_context(), timeout=5) as response, open(path, 'wb') as f: shutil.copyfileobj(response, f) From 2524cf5f1922e5930fa9f50931e7c7b1a0a7b75c Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Fri, 21 Jun 2019 14:32:53 -0400 Subject: [PATCH 20/37] update build steps and add some troubleshooting --- README.md | 50 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index eba1473..5397fa3 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,7 @@ ## About The QGIS STAC Browser plugin allows for searching STAC catalogs for assets and downloading those assets directly into QGIS. -## Building - -To build the plugin and deploy to your plugin directory you will need the [pb_tool](http://g-sherman.github.io/plugin_build_tool/) CLI tool. -To compile the plugin run `pb_tool compile` in the root directory of this repository. -Compiling is needed any time the resources.py file needs to be rebuilt. -To deploy the application to your QGIS plugins directory run `pb_tool` deploy and reload the plugin within QGIS. -It's recommended to use the Plugin Reloader plugin within QGIS to easily reload the plugin during development. ## Current version and branches The [master branch](https://github.com/kbgg/qgis-stac-browser/tree/master) is the 'stable' version of the plugin. It is currently version @@ -19,3 +12,46 @@ The [master branch](https://github.com/kbgg/qgis-stac-browser/tree/master) is th Whenever dev stabilizes a release is cut and we merge dev in to master. So master should be stable at any given time. It is possible that there may be small releases in quick succession, especially if they are nice improvements that do not require lots of updating. + +## Building + +To build the plugin and deploy to your plugin directory you will need the [pb_tool](http://g-sherman.github.io/plugin_build_tool/) CLI tool. + +To compile the plugin run the following command in the root directory of this repository: + + pb_tool compile + +Compiling is needed any time the resources.py file needs to be rebuilt. + +To deploy the application to your QGIS plugins directory run the following command and reload the plugin within QGIS: + + pb_tool deploy + +It's recommended to use the Plugin Reloader plugin within QGIS to easily reload the plugin during development. + +### Troubleshooting + +#### pyuic5 is not in your path + + pyuic5 is not in your path---unable to compile your ui files + pyrcc5 is not in your path---unable to compile your resource file(s) + +Fix: + + export PATH=/anaconda3/bin:$PATH + +#### QGIS cannot find plugin + +Change pb_tool.cfg settings: + +Mac + + plugin_path: /Users/{USER}/Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins/ + +Linux + + plugin_path: /home/{USER}/.local/share/QGIS/QGIS3/profiles/default/python/plugins + +Windows + + plugin_path: C:\Users\{USER}\AppData\Roaming\QGIS\QGIS3\profiles\default\python\plugins From a7ef8d957db81fc6db2eee097103449dd0a36635 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Fri, 21 Jun 2019 13:33:06 -0500 Subject: [PATCH 21/37] Added configure api list menu --- assets/cog.svg | 9 ++ assets/info.svg | 7 + controllers/add_edit_api_dialog.py | 85 +++++++++++ controllers/configure_apis_dialog.py | 145 +++++++++++++++++++ models/api.py | 17 ++- resources.qrc | 2 + stac_browser.py | 27 ++++ threads/load_api_data_thread.py | 24 ++++ utils/config.py | 3 + views/add_edit_api_dialog.ui | 110 ++++++++++++++ views/configure_apis_dialog.ui | 206 +++++++++++++++++++++++++++ 11 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 assets/cog.svg create mode 100644 assets/info.svg create mode 100644 controllers/add_edit_api_dialog.py create mode 100644 controllers/configure_apis_dialog.py create mode 100644 threads/load_api_data_thread.py create mode 100644 views/add_edit_api_dialog.ui create mode 100644 views/configure_apis_dialog.ui diff --git a/assets/cog.svg b/assets/cog.svg new file mode 100644 index 0000000..9a4b609 --- /dev/null +++ b/assets/cog.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/assets/info.svg b/assets/info.svg new file mode 100644 index 0000000..32742e6 --- /dev/null +++ b/assets/info.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/controllers/add_edit_api_dialog.py b/controllers/add_edit_api_dialog.py new file mode 100644 index 0000000..b75ba07 --- /dev/null +++ b/controllers/add_edit_api_dialog.py @@ -0,0 +1,85 @@ +import uuid + +from PyQt5 import uic, QtWidgets, QtGui + +from ..utils import ui +from ..utils.logging import debug, info, warning, error + +from ..threads.load_api_data_thread import LoadAPIDataThread +from ..models.api import API + +FORM_CLASS, _ = uic.loadUiType(ui.path('add_edit_api_dialog.ui')) + +class AddEditAPIDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None, iface=None): + super(AddEditAPIDialog, self).__init__(parent) + + self.data = data + self.hooks = hooks + self.iface = iface + + self.setupUi(self) + + self.populate_details() + self.populate_auth_method_combo() + + self.cancelButton.clicked.connect(self.on_cancel_clicked) + self.removeButton.clicked.connect(self.on_remove_clicked) + self.saveAddButton.clicked.connect(self.on_save_add_clicked) + + def on_cancel_clicked(self): + self.reject() + + def on_remove_clicked(self): + self.hooks['remove_api'](self.api) + self.accept() + + def set_all_enabled(self, enabled): + self.urlEditBox.setEnabled(enabled) + self.authenticationCombo.setEnabled(enabled) + self.removeButton.setEnabled(enabled) + self.cancelButton.setEnabled(enabled) + self.saveAddButton.setEnabled(enabled) + + def on_save_add_clicked(self): + self.set_all_enabled(False) + self.saveAddButton.setText('Testing Connection...') + + api_id = str(uuid.uuid4()) + if self.api is not None: + api_id = self.api.id + + api = API({'id': api_id, 'href': self.urlEditBox.text()}) + self.loading_thread = LoadAPIDataThread(api, on_error=self.on_api_error, on_finished=self.on_api_success) + self.loading_thread.start() + + def on_api_error(self, e): + self.set_all_enabled(True) + if self.api is None: + self.saveAddButton.setText('Add') + else: + self.saveAddButton.setText('Save') + + error(self.iface, f'Connection Failed; {e.reason}') + + def on_api_success(self, api): + if self.api is None: + self.hooks['add_api'](api) + else: + self.hooks['edit_api'](api) + self.accept() + + @property + def api(self): + return self.data.get('api', None) + + def populate_details(self): + if self.api is None: + self.saveAddButton.setText('Add') + self.removeButton.hide() + return + + self.urlEditBox.setText(self.api.href) + + def populate_auth_method_combo(self): + self.authenticationCombo.addItem('No Auth') diff --git a/controllers/configure_apis_dialog.py b/controllers/configure_apis_dialog.py new file mode 100644 index 0000000..84c7255 --- /dev/null +++ b/controllers/configure_apis_dialog.py @@ -0,0 +1,145 @@ +from PyQt5 import uic, QtWidgets, QtGui + +from ..utils import ui +from ..utils.config import Config +from ..utils.logging import debug, info, warning, error + +from ..controllers.add_edit_api_dialog import AddEditAPIDialog + + +FORM_CLASS, _ = uic.loadUiType(ui.path('configure_apis_dialog.ui')) + +class ConfigureAPIDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, data={}, hooks={}, parent=None, iface=None): + super(ConfigureAPIDialog, self).__init__(parent) + + self.data = data + self.hooks = hooks + self.iface = iface + + self.setupUi(self) + + self.populate_api_list() + self.populate_api_details() + + self.list.activated.connect(self.on_list_clicked) + + self.apiAddButton.clicked.connect(self.on_add_api_clicked) + self.apiEditButton.clicked.connect(self.on_edit_api_clicked) + self.closeButton.clicked.connect(self.on_close_clicked) + + def on_close_clicked(self): + self.reject() + + def on_add_api_clicked(self): + dialog = AddEditAPIDialog(data={'api': None}, + hooks={ + "remove_api": self.remove_api, + "add_api": self.add_api, + "edit_api": self.edit_api + }, + parent=self, + iface=self.iface) + result = dialog.exec_() + + def on_edit_api_clicked(self): + dialog = AddEditAPIDialog(data={'api': self.selected_api}, + hooks={ + "remove_api": self.remove_api, + "add_api": self.add_api, + "edit_api": self.edit_api + }, + parent=self, + iface=self.iface) + result = dialog.exec_() + + def edit_api(self, api): + config = Config() + new_apis = [] + + for a in config.apis: + if a.id == api.id: + continue + new_apis.append(a) + new_apis.append(api) + config.apis = new_apis + config.save() + + self.data['apis'] = config.apis + self.populate_api_list() + self.populate_api_details() + + def add_api(self, api): + config = Config() + apis = config.apis + apis.append(api) + config.apis = apis + config.save() + + self.data['apis'] = config.apis + self.populate_api_list() + self.populate_api_details() + + def remove_api(self, api): + config = Config() + new_apis = [] + + for a in config.apis: + if a.id == api.id: + continue + new_apis.append(a) + + config.apis = new_apis + config.save() + + self.data['apis'] = config.apis + self.populate_api_list() + self.populate_api_details() + + def populate_api_list(self): + self.list.clear() + for api in self.apis: + api_node = QtWidgets.QListWidgetItem(self.list) + api_node.setText(f'{api.title}') + + def populate_api_details(self): + if self.selected_api is None: + self.apiUrlLabel.hide() + self.apiUrlValue.hide() + self.apiTitleLabel.hide() + self.apiTitleValue.hide() + self.apiVersionLabel.hide() + self.apiVersionValue.hide() + self.apiDescriptionLabel.hide() + self.apiDescriptionValue.hide() + self.apiEditButton.hide() + return + + self.apiUrlValue.setText(self.selected_api.href) + self.apiTitleValue.setText(self.selected_api.title) + self.apiVersionValue.setText(self.selected_api.version) + self.apiDescriptionValue.setText(self.selected_api.description) + + self.apiUrlLabel.show() + self.apiUrlValue.show() + self.apiTitleLabel.show() + self.apiTitleValue.show() + self.apiVersionLabel.show() + self.apiVersionValue.show() + self.apiDescriptionLabel.show() + self.apiDescriptionValue.show() + self.apiEditButton.show() + + @property + def apis(self): + return self.data.get('apis', []) + + @property + def selected_api(self): + items = self.list.selectedIndexes() + for i in items: + return self.apis[i.row()] + return None + + def on_list_clicked(self): + self.populate_api_details() diff --git a/models/api.py b/models/api.py index 6f1c54d..9a59dd8 100644 --- a/models/api.py +++ b/models/api.py @@ -58,21 +58,36 @@ def collection_id_from_href(self, href): @property def json(self): return { + 'id': self.id, 'href': self.href, 'data': self.data, 'collections': [c.json for c in self.collections], } + @property + def id(self): + return self._json.get('id', None) + @property def title(self): - return self.data.get('title', None) + return self.data.get('title', self.href) @property def href(self): return self._json.get('href', None) + @property + def version(self): + return self.data.get('stac_version', None) + + @property + def description(self): + return self.data.get('description', None) + @property def data(self): + if self._data is None: + return {} return self._data @property diff --git a/resources.qrc b/resources.qrc index d8775c5..f528ea0 100644 --- a/resources.qrc +++ b/resources.qrc @@ -1,6 +1,8 @@ assets/icon.png + assets/cog.svg + assets/info.svg diff --git a/stac_browser.py b/stac_browser.py index 23fc963..425a6e9 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -12,6 +12,7 @@ from .controllers.results_dialog import ResultsDialog from .controllers.downloading_controller import DownloadController from .controllers.download_selection_dialog import DownloadSelectionDialog +from .controllers.configure_apis_dialog import ConfigureAPIDialog from .utils.config import Config from .utils.logging import debug, info, warning, error @@ -206,6 +207,16 @@ def reset_windows(self): window['dialog'] = None self.current_window = 'COLLECTION_LOADING' + def configure_apis(self): + dialog = ConfigureAPIDialog(data={ 'apis': Config().apis }, + hooks={}, + parent=self.iface.mainWindow(), + iface=self.iface) + result = dialog.exec_() + + def about(self): + return + def initGui(self): icon_path = ':/plugins/stac_browser/assets/icon.png' self.add_action( @@ -214,6 +225,22 @@ def initGui(self): callback=self.load_window, parent=self.iface.mainWindow()) + icon_path = ':/plugins/stac_browser/assets/cog.svg' + self.add_action( + icon_path, + text='Configure APIs', + add_to_toolbar=False, + callback=self.configure_apis, + parent=self.iface.mainWindow()) + + icon_path = ':/plugins/stac_browser/assets/info.svg' + self.add_action( + icon_path, + text='About', + add_to_toolbar=False, + callback=self.about, + parent=self.iface.mainWindow()) + def unload(self): for action in self.actions: self.iface.removePluginWebMenu(u'&STAC Browser', action) diff --git a/threads/load_api_data_thread.py b/threads/load_api_data_thread.py new file mode 100644 index 0000000..0ba1fa1 --- /dev/null +++ b/threads/load_api_data_thread.py @@ -0,0 +1,24 @@ +from PyQt5.QtCore import QThread, pyqtSignal +from urllib.error import URLError +from ..models.api import API + +class LoadAPIDataThread(QThread): + error_signal = pyqtSignal(Exception) + finished_signal = pyqtSignal(API) + + def __init__(self, api, on_error=None, on_finished=None): + QThread.__init__(self) + self.api = api + + self.on_error = on_error + self.on_finished = on_finished + + self.error_signal.connect(self.on_error) + self.finished_signal.connect(self.on_finished) + + def run(self): + try: + self.api.load() + self.finished_signal.emit(self.api) + except URLError as e: + self.error_signal.emit(e) diff --git a/utils/config.py b/utils/config.py index 6705350..bf495a3 100644 --- a/utils/config.py +++ b/utils/config.py @@ -36,12 +36,15 @@ def apis(self): if apis is None: apis = [ { + "id": "default-staccato", "href": "https://stac.boundlessgeo.io", }, { + "id": "default-sat-api", "href": "https://sat-api.developmentseed.org", }, { + "id": "default-astraea", "href": "https://stac.astraea.earth/api/v2", } ] diff --git a/views/add_edit_api_dialog.ui b/views/add_edit_api_dialog.ui new file mode 100644 index 0000000..4f48c78 --- /dev/null +++ b/views/add_edit_api_dialog.ui @@ -0,0 +1,110 @@ + + + STACBrowserDialogBase + + + + 0 + 0 + 385 + 139 + + + + STAC Browser + + + + + + + + QLayout::SetMaximumSize + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + URL + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Authentication + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 150 + 16777215 + + + + + + + + + + + + Remove + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + Save + + + + + + + + + + + + + + diff --git a/views/configure_apis_dialog.ui b/views/configure_apis_dialog.ui new file mode 100644 index 0000000..7f93975 --- /dev/null +++ b/views/configure_apis_dialog.ui @@ -0,0 +1,206 @@ + + + STACBrowserDialogBase + + + + 0 + 0 + 680 + 230 + + + + STAC Browser + + + + + + + + + 0 + 0 + + + + + + + + QLayout::SetMaximumSize + + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + URL + + + Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing + + + + + + + Title + + + Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing + + + + + + + Description + + + Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing + + + + + + + STAC Version + + + Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing + + + + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + 250 + 16777215 + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Edit + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Add API + + + + + + + Close + + + + + + + + + + + + + + From 26a17760284c8fbd3c4c712d76f938c278685326 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Fri, 21 Jun 2019 13:55:26 -0500 Subject: [PATCH 22/37] Added about dialog --- about.html | 15 ++++++++++ controllers/about_dialog.py | 23 +++++++++++++++ pb_tool.cfg | 2 +- stac_browser.py | 4 ++- views/about_dialog.ui | 56 +++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 about.html create mode 100644 controllers/about_dialog.py create mode 100644 views/about_dialog.ui diff --git a/about.html b/about.html new file mode 100644 index 0000000..aae8000 --- /dev/null +++ b/about.html @@ -0,0 +1,15 @@ + + +

QGIS STAC Browser

+
The QGIS STAC Browser plugin allows for searching STAC catalogs for assets and downloading those assets directly into QGIS. +

Documentation and Source Code

+
https://github.com/kbgg/qgis-stac-browser +

Issues or Enhancements

+
https://github.com/kbgg/qgis-stac-browser/issues +

Contact

+
Kevin Booth +
Twitter: @kbgg_ +
Email: kevin@kb.gg +

License

+
Apache License 2.0 + diff --git a/controllers/about_dialog.py b/controllers/about_dialog.py new file mode 100644 index 0000000..89f292c --- /dev/null +++ b/controllers/about_dialog.py @@ -0,0 +1,23 @@ +from PyQt5 import uic, QtWidgets + +from ..utils import ui + + +FORM_CLASS, _ = uic.loadUiType(ui.path('about_dialog.ui')) + +class AboutDialog(QtWidgets.QDialog, FORM_CLASS): + def __init__(self, path=None, parent=None, iface=None): + super(AboutDialog, self).__init__(parent) + self.iface = iface + + self.setupUi(self) + + if path is not None: + with open(path, 'r') as f: + contents = f.read() + self.textBrowser.setHtml(contents) + + self.closeButton.clicked.connect(self.on_close_clicked) + + def on_close_clicked(self): + self.reject() diff --git a/pb_tool.cfg b/pb_tool.cfg index ce19008..c527548 100644 --- a/pb_tool.cfg +++ b/pb_tool.cfg @@ -60,7 +60,7 @@ compiled_ui_files: resource_files: resources.qrc # Other files required for the plugin -extras: metadata.txt +extras: metadata.txt about.html # Other directories to be deployed with the plugin. # These must be subdirectories under the plugin directory diff --git a/stac_browser.py b/stac_browser.py index 425a6e9..2318cc7 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -13,6 +13,7 @@ from .controllers.downloading_controller import DownloadController from .controllers.download_selection_dialog import DownloadSelectionDialog from .controllers.configure_apis_dialog import ConfigureAPIDialog +from .controllers.about_dialog import AboutDialog from .utils.config import Config from .utils.logging import debug, info, warning, error @@ -215,7 +216,8 @@ def configure_apis(self): result = dialog.exec_() def about(self): - return + dialog = AboutDialog(os.path.join(self.plugin_dir, 'about.html'), parent=self.iface.mainWindow(), iface=self.iface) + result = dialog.exec_() def initGui(self): icon_path = ':/plugins/stac_browser/assets/icon.png' diff --git a/views/about_dialog.ui b/views/about_dialog.ui new file mode 100644 index 0000000..62a607a --- /dev/null +++ b/views/about_dialog.ui @@ -0,0 +1,56 @@ + + + STACBrowserDialogBase + + + + 0 + 0 + 610 + 516 + + + + STAC Browser + + + + + + + + true + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + + + From 98f7ebb10399e22925e7d369c6212f3585a0d909 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Fri, 21 Jun 2019 14:08:55 -0500 Subject: [PATCH 23/37] Set default download directory to /Users/kevin --- utils/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/config.py b/utils/config.py index bf495a3..9f5ae4c 100644 --- a/utils/config.py +++ b/utils/config.py @@ -69,6 +69,8 @@ def last_update(self, value): @property def download_directory(self): + if self._json.get('download_directory', None) is None: + return os.environ.get('HOME', '') return self._json.get('download_directory', '') @download_directory.setter From 05150f8e5ff1bff7acb2bda5cfde3315c0149499 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Fri, 21 Jun 2019 20:16:03 -0500 Subject: [PATCH 24/37] Added error if building virtual raster fails and utility for finding gdal directory --- controllers/downloading_controller.py | 4 ++++ models/item.py | 4 ++-- threads/download_items_thread.py | 11 +++++++++-- utils/fs.py | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 utils/fs.py diff --git a/controllers/downloading_controller.py b/controllers/downloading_controller.py index 43040c7..3f4fb2c 100644 --- a/controllers/downloading_controller.py +++ b/controllers/downloading_controller.py @@ -22,6 +22,7 @@ def __init__(self, data={}, hooks={}, iface=None): self.loading_thread = DownloadItemsThread(self.downloads, self.download_directory, on_progress=self.on_progress_update, + on_gdal_error=self.on_gdal_error, on_error=self.on_error, on_add_layer=self.on_add_layer, on_finished=self.on_downloading_finished) @@ -36,6 +37,9 @@ def downloads(self): def download_directory(self): return self.data.get('download_directory', None) + def on_gdal_error(self, e): + error(self.iface, f'Unable to find \'gdalbuildvrt\' in current path') + def on_error(self, item, e): error(self.iface, f'Failed to load {item.id}; {e.reason}') diff --git a/models/item.py b/models/item.py index 740cc83..6882c6c 100644 --- a/models/item.py +++ b/models/item.py @@ -109,7 +109,7 @@ def download_steps(self, options): steps += 1 return steps - def download(self, options, download_directory, on_update=None): + def download(self, gdal_path, options, download_directory, on_update=None): item_download_directory = os.path.join(download_directory, self.id) if not os.path.exists(item_download_directory): os.makedirs(item_download_directory) @@ -137,7 +137,7 @@ def download(self, options, download_directory, on_update=None): if on_update is not None: on_update(f'Building Virtual Raster...') - arguments = ['gdalbuildvrt', '-separate', os.path.join(download_directory, f'{self.id}.vrt')] + arguments = [os.path.join(gdal_path, 'gdalbuildvrt'), '-separate', os.path.join(download_directory, f'{self.id}.vrt')] arguments.extend(raster_filenames) subprocess.run(arguments) diff --git a/threads/download_items_thread.py b/threads/download_items_thread.py index 4dc5909..4caf986 100644 --- a/threads/download_items_thread.py +++ b/threads/download_items_thread.py @@ -1,20 +1,23 @@ from PyQt5.QtCore import QThread, pyqtSignal from urllib.error import URLError from ..models.item import Item +from ..utils import fs class DownloadItemsThread(QThread): progress_signal = pyqtSignal(int, int, str) + gdal_error_signal = pyqtSignal(Exception) error_signal = pyqtSignal(Item, Exception) add_layer_signal = pyqtSignal(int, int, Item, str) finished_signal = pyqtSignal() - def __init__(self, downloads, download_directory, on_progress=None, on_error=None, on_add_layer=None, on_finished=None): + def __init__(self, downloads, download_directory, on_progress=None, on_error=None, on_gdal_error=None, on_add_layer=None, on_finished=None): QThread.__init__(self) self.downloads = downloads self.download_directory = download_directory self.on_progress=on_progress self.on_error = on_error + self.on_gdal_error = on_gdal_error self.on_add_layer = on_add_layer self.on_finished=on_finished @@ -29,20 +32,24 @@ def __init__(self, downloads, download_directory, on_progress=None, on_error=Non self.progress_signal.connect(self.on_progress) self.error_signal.connect(self.on_error) + self.gdal_error_signal.connect(self.on_gdal_error) self.add_layer_signal.connect(self.on_add_layer) self.finished_signal.connect(self.on_finished) def run(self): + gdal_path = fs.gdal_path() for i, download in enumerate(self.downloads): self._current_item = i item = download['item'] options = download['options'] try: - item.download(options, self.download_directory, on_update=self.on_update) + item.download(gdal_path, options, self.download_directory, on_update=self.on_update) if options.get('add_to_layers', False): self.add_layer_signal.emit(self._current_step, self._total_steps, item, self.download_directory) except URLError as e: self.error_signal.emit(item, e) + except FileNotFoundError as e: + self.gdal_error_signal.emit(e) self.finished_signal.emit() def on_update(self, status): diff --git a/utils/fs.py b/utils/fs.py new file mode 100644 index 0000000..bd10e71 --- /dev/null +++ b/utils/fs.py @@ -0,0 +1,19 @@ +import os +import subprocess + +def gdal_path(): + common_paths = [ + '', + '/Library/Frameworks/GDAL.framework/Programs', + '/usr/local/bin' + ] + + for common_path in common_paths: + try: + subprocess.run([os.path.join(common_path, 'gdalbuildvrt'), '--version']) + return common_path + except FileNotFoundError as e: + continue + + return None + From 5300d86d012d0c41372abdbfb719b512a0fc47d9 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Fri, 21 Jun 2019 20:51:41 -0500 Subject: [PATCH 25/37] Added support for new paging extension, timeout erroring --- controllers/add_edit_api_dialog.py | 6 +++++- controllers/collection_loading_dialog.py | 6 +++++- controllers/downloading_controller.py | 6 +++++- controllers/item_loading_dialog.py | 6 +++++- models/api.py | 13 ++++++++++--- models/search_result.py | 7 +++++++ threads/download_items_thread.py | 3 +++ threads/load_api_data_thread.py | 3 +++ threads/load_collections_thread.py | 3 +++ threads/load_items_thread.py | 3 +++ threads/load_preview_thread.py | 3 +++ 11 files changed, 52 insertions(+), 7 deletions(-) diff --git a/controllers/add_edit_api_dialog.py b/controllers/add_edit_api_dialog.py index b75ba07..1ad75ee 100644 --- a/controllers/add_edit_api_dialog.py +++ b/controllers/add_edit_api_dialog.py @@ -1,4 +1,5 @@ import uuid +import urllib from PyQt5 import uic, QtWidgets, QtGui @@ -60,7 +61,10 @@ def on_api_error(self, e): else: self.saveAddButton.setText('Save') - error(self.iface, f'Connection Failed; {e.reason}') + if type(e) == urllib.error.URLError: + error(self.iface, f'Connection Failed; {e.reason}') + else: + error(self.iface, f'Connection Failed; {type(e).__name__}') def on_api_success(self, api): if self.api is None: diff --git a/controllers/collection_loading_dialog.py b/controllers/collection_loading_dialog.py index 0598633..c2c3e39 100644 --- a/controllers/collection_loading_dialog.py +++ b/controllers/collection_loading_dialog.py @@ -1,4 +1,5 @@ import time +import urllib from PyQt5 import uic, QtWidgets @@ -34,7 +35,10 @@ def on_progress_update(self, progress, api): self.progressBar.setValue(int(progress*100)) def on_error(self, e, api): - error(self.iface, f'Failed to load {api.href}; {e.reason}') + if type(e) == urllib.error.URLError: + error(self.iface, f'Failed to load {api.href}; {e.reason}') + else: + error(self.iface, f'Failed to load {api.href}; {type(e).__name__}') def on_loading_finished(self, apis): config = Config() diff --git a/controllers/downloading_controller.py b/controllers/downloading_controller.py index 3f4fb2c..5b47bc7 100644 --- a/controllers/downloading_controller.py +++ b/controllers/downloading_controller.py @@ -1,4 +1,5 @@ import os +import urllib from qgis.core import QgsRasterLayer, QgsProject, Qgis @@ -41,7 +42,10 @@ def on_gdal_error(self, e): error(self.iface, f'Unable to find \'gdalbuildvrt\' in current path') def on_error(self, item, e): - error(self.iface, f'Failed to load {item.id}; {e.reason}') + if type(e) == urllib.error.URLError: + error(self.iface, f'Failed to load {item.id}; {e.reason}') + else: + error(self.iface, f'Failed to load {item.id}; {type(e).__name__}') def on_add_layer(self, current_step, total_steps, item, download_directory): self.on_progress_update(current_step, total_steps, 'ADDING_TO_LAYERS') diff --git a/controllers/item_loading_dialog.py b/controllers/item_loading_dialog.py index 85d1743..28ff9be 100644 --- a/controllers/item_loading_dialog.py +++ b/controllers/item_loading_dialog.py @@ -1,4 +1,5 @@ from PyQt5 import uic, QtWidgets +import urllib from ..utils.config import Config from ..utils import ui @@ -34,7 +35,10 @@ def on_progress(self, api, collections, current_page): self.loadingLabel.setText(f'Searching {api.title}\nCollections: [{collection_label}]\nPage {current_page}...') def on_error(self, e): - error(self.iface, f'Network Error: {e.reason}') + if type(e) == urllib.error.URLError: + error(self.iface, f'Network Error: {e.reason}') + else: + error(self.iface, f'Network Error: {type(e).__name__}') self.hooks['on_error']() def on_finished(self, items): diff --git a/models/api.py b/models/api.py index 9a59dd8..ba7158c 100644 --- a/models/api.py +++ b/models/api.py @@ -19,7 +19,10 @@ def load_collection(self, collection_id): return Collection(self, network.request(f'{self.href}/collections/{collection_id}')) def search_items(self, collections=[], bbox=[], start_time=None, - end_time=None, page=1, limit=50, on_next_page=None): + end_time=None, page=1, next_page=None, limit=50, on_next_page=None, page_limit=10): + if page > page_limit: + return [] + if on_next_page is not None: on_next_page(self) @@ -32,15 +35,19 @@ def search_items(self, collections=[], bbox=[], start_time=None, 'collections': [c.id for c in collections], 'bbox': bbox, 'time': time, - 'page': page, 'limit': limit } + + if next_page is not None: + body['next'] = next_page + else: + body['page'] = page search_result = SearchResult(self, network.request(f'{self.href}/stac/search', data=body)) items = search_result.items if len(items) >= limit: - items.extend(self.search_items(collections, bbox, start_time, end_time, page+1, limit, on_next_page=on_next_page)) + items.extend(self.search_items(collections, bbox, start_time, end_time, page+1, search_result.next, limit, on_next_page=on_next_page)) return items diff --git a/models/search_result.py b/models/search_result.py index d9412d5..ad1d8a3 100644 --- a/models/search_result.py +++ b/models/search_result.py @@ -18,6 +18,13 @@ def type(self): def meta(self): return self._json.get('meta', None) + @property + def next(self): + if self._json.get('search:metadata', None) is None: + return None + + return self._json.get('search:metadata', {}).get('next', None) + @property def items(self): return [Item(self.api, f) for f in self._json.get('features', [])] diff --git a/threads/download_items_thread.py b/threads/download_items_thread.py index 4caf986..1b92dcc 100644 --- a/threads/download_items_thread.py +++ b/threads/download_items_thread.py @@ -1,3 +1,4 @@ +import socket from PyQt5.QtCore import QThread, pyqtSignal from urllib.error import URLError from ..models.item import Item @@ -48,6 +49,8 @@ def run(self): self.add_layer_signal.emit(self._current_step, self._total_steps, item, self.download_directory) except URLError as e: self.error_signal.emit(item, e) + except socket.timeout as e: + self.error_signal.emit(item, e) except FileNotFoundError as e: self.gdal_error_signal.emit(e) self.finished_signal.emit() diff --git a/threads/load_api_data_thread.py b/threads/load_api_data_thread.py index 0ba1fa1..e80d2b4 100644 --- a/threads/load_api_data_thread.py +++ b/threads/load_api_data_thread.py @@ -1,3 +1,4 @@ +import socket from PyQt5.QtCore import QThread, pyqtSignal from urllib.error import URLError from ..models.api import API @@ -22,3 +23,5 @@ def run(self): self.finished_signal.emit(self.api) except URLError as e: self.error_signal.emit(e) + except socket.timeout as e: + self.error_signal.emit(e) diff --git a/threads/load_collections_thread.py b/threads/load_collections_thread.py index b049e3c..107d032 100644 --- a/threads/load_collections_thread.py +++ b/threads/load_collections_thread.py @@ -1,3 +1,4 @@ +import socket from PyQt5.QtCore import QThread, pyqtSignal from urllib.error import URLError from ..models.api import API @@ -29,5 +30,7 @@ def run(self): apis.append(api) except URLError as e: self.error_signal.emit(e, api) + except socket.timeout as e: + self.error_signal.emit(e, api) self.finished_signal.emit(apis) diff --git a/threads/load_items_thread.py b/threads/load_items_thread.py index 67d58b2..985e99f 100644 --- a/threads/load_items_thread.py +++ b/threads/load_items_thread.py @@ -1,3 +1,4 @@ +import socket from PyQt5.QtCore import QThread, pyqtSignal from urllib.error import URLError from ..models.api import API @@ -43,6 +44,8 @@ def run(self): self.finished_signal.emit(all_items) except URLError as e: self.error_signal.emit(e) + except socket.timeout as e: + self.error_signal.emit(e) def on_next_page(self, api): self.current_page += 1 diff --git a/threads/load_preview_thread.py b/threads/load_preview_thread.py index ceea66a..0691f98 100644 --- a/threads/load_preview_thread.py +++ b/threads/load_preview_thread.py @@ -1,3 +1,4 @@ +import socket from PyQt5.QtCore import QThread, pyqtSignal from urllib.error import URLError from ..utils import network @@ -19,3 +20,5 @@ def run(self): self.finished_signal.emit(self.item, False) except URLError as e: self.finished_signal.emit(self.item, True) + except socket.timeout as e: + self.finished_signal.emit(self.item, True) From 19bcc8828e17af2eea6e5598745e95eddb1e2f7f Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 11:24:38 -0500 Subject: [PATCH 26/37] Testing CircleCI --- .circleci/config.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..ab6844d --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,15 @@ +# Use the latest 2.1 version of CircleCI pipeline processing engine, see https://circleci.com/docs/2.0/configuration-reference/ +version: 2.1 + +# Use a package of configuration called an orb, see https://circleci.com/docs/2.0/orb-intro/ +orbs: + # Declare a dependency on the welcome-orb + welcome: circleci/welcome-orb@0.3.1 + +# Orchestrate or schedule a set of jobs, see https://circleci.com/docs/2.0/workflows/ +workflows: + # Name the workflow "Welcome" + Welcome: + # Run the welcome/run job in its own container + jobs: + - welcome/run From 8ae484617a6e29379bb8cc204bedd69a0f1dc92e Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 12:03:09 -0500 Subject: [PATCH 27/37] Adding flake test --- .circleci/config.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ab6844d..3a36ef8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,15 +1,7 @@ -# Use the latest 2.1 version of CircleCI pipeline processing engine, see https://circleci.com/docs/2.0/configuration-reference/ version: 2.1 - -# Use a package of configuration called an orb, see https://circleci.com/docs/2.0/orb-intro/ orbs: - # Declare a dependency on the welcome-orb - welcome: circleci/welcome-orb@0.3.1 - -# Orchestrate or schedule a set of jobs, see https://circleci.com/docs/2.0/workflows/ + flake8: arrai/flake8@5.0.0 workflows: - # Name the workflow "Welcome" - Welcome: - # Run the welcome/run job in its own container - jobs: - - welcome/run + Test: + jobs: + - flake8/flake8 From 1723e7e3651124db956c21624deb3a2767b2abc1 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 12:17:20 -0500 Subject: [PATCH 28/37] Test --- .circleci/config.yml | 125 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3a36ef8..c4f17a8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,124 @@ version: 2.1 -orbs: - flake8: arrai/flake8@5.0.0 +commands: + lint: + description: Run flake8 on files with git diff + parameters: + working-directory: &working-directory + default: . + type: string + description: Directory path for this job + checkout: &checkout + default: true + type: boolean + description: Bool to checkout as a first step + attach-workspace: &attach-workspace + default: false + type: boolean + description: Bool to attach to an existing workspace + workspace-root: &workspace-root + default: . + type: string + description: Workspace root path + compare-to-pr-root: &compare-to-pr-root + default: true + type: boolean + description: Use GITHUB_API_TOKEN to find where pull is getting merged into and find all changed files + + steps: + - when: + condition: << parameters.checkout >> + steps: + - checkout + - when: + condition: << parameters.attach-workspace >> + steps: + - attach_workspace: + at: << parameters.workspace-root >> + + - when: + condition: << parameters.compare-to-pr-root >> + steps: + - run: + name: Find all files differing and then flake8 + command: | + if [[ -z "${CIRCLE_PULL_REQUEST}" ]]; then + echo "No pull request. Skipping title validation." + exit 0 + elif [[ -z "${GITHUB_API_TOKEN}" ]]; then + echo "ERROR: No Github API token. Must have valid token to run job." + exit 1 + fi + + sudo pip install flake8==3.7.7 + + CIRCLE_PR_NUMBER=$(echo "import re; print re.findall(r'\d+$','${CIRCLE_PULL_REQUEST}')[0]" | python) + URL="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls/${CIRCLE_PR_NUMBER}" + MERGE_TO_BRANCH=$(curl -s -X GET -G ${URL} -d access_token=${GITHUB_API_TOKEN} | jq '.base.ref' | tr -d '"') + + git checkout -q ${MERGE_TO_BRANCH} + git reset --hard -q origin/${MERGE_TO_BRANCH} + git checkout -q ${CIRCLE_BRANCH} + echo "checked out ${CIRCLE_BRANCH} from ${MERGE_TO_BRANCH}" + + CHANGED_FILES=$(git diff --name-only ${MERGE_TO_BRANCH}..${CIRCLE_BRANCH} -- '*.py') + echo ${CHANGED_FILES} + echo ${CHANGED_FILES} | xargs --no-run-if-empty flake8 + + - unless: + condition: << parameters.compare-to-pr-root >> + steps: + - run: + name: Find most recent files differing and then flake8 + command: | + if [[ -z "$CIRCLE_PULL_REQUEST" ]]; then + echo "ERROR: No pull request. Must have pull request associated with your code." + exit 1 + fi + + sudo pip install flake8==3.7.7 + + CHANGED_FILES=$(git diff --name-only ${CIRCLE_BRANCH}~1 -- '*.py') + echo ${CHANGED_FILES} + echo ${CHANGED_FILES} | xargs --no-run-if-empty flake8 + +executors: + python-lint: + docker: + - image: circleci/python:<< parameters.pyversion >> + working_directory: << parameters.working-directory >> + parameters: + pyversion: &pyversion + default: '3.6' + description: Python version for executor + type: string + working-directory: *working-directory + resource_class: small + +jobs: + lint: + description: Lint python files + + parameters: + pyversion: *pyversion + working-directory: *working-directory + checkout: *checkout + attach-workspace: *attach-workspace + workspace-root: *workspace-root + compare-to-pr-root: *compare-to-pr-root + + executor: + name: python-lint + pyversion: << parameters.pyversion >> + working-directory: << parameters.working-directory >> + + steps: + - lint: + working-directory: <> + checkout: << parameters.checkout >> + attach-workspace: << parameters.attach-workspace >> + workspace-root: << parameters.workspace-root >> + compare-to-pr-root: << parameters.compare-to-pr-root >> workflows: - Test: + build: jobs: - - flake8/flake8 + - lint From b4dd22ca9465b929be1af2d8d4ace89355f6917c Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 12:34:41 -0500 Subject: [PATCH 29/37] Updated CircleCI config to run linter on build --- .circleci/config.yml | 134 +++++-------------------------------------- 1 file changed, 15 insertions(+), 119 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c4f17a8..f0d1390 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,124 +1,20 @@ -version: 2.1 -commands: +version: 2 +jobs: lint: - description: Run flake8 on files with git diff - parameters: - working-directory: &working-directory - default: . - type: string - description: Directory path for this job - checkout: &checkout - default: true - type: boolean - description: Bool to checkout as a first step - attach-workspace: &attach-workspace - default: false - type: boolean - description: Bool to attach to an existing workspace - workspace-root: &workspace-root - default: . - type: string - description: Workspace root path - compare-to-pr-root: &compare-to-pr-root - default: true - type: boolean - description: Use GITHUB_API_TOKEN to find where pull is getting merged into and find all changed files - - steps: - - when: - condition: << parameters.checkout >> - steps: - - checkout - - when: - condition: << parameters.attach-workspace >> - steps: - - attach_workspace: - at: << parameters.workspace-root >> - - - when: - condition: << parameters.compare-to-pr-root >> - steps: - - run: - name: Find all files differing and then flake8 - command: | - if [[ -z "${CIRCLE_PULL_REQUEST}" ]]; then - echo "No pull request. Skipping title validation." - exit 0 - elif [[ -z "${GITHUB_API_TOKEN}" ]]; then - echo "ERROR: No Github API token. Must have valid token to run job." - exit 1 - fi - - sudo pip install flake8==3.7.7 - - CIRCLE_PR_NUMBER=$(echo "import re; print re.findall(r'\d+$','${CIRCLE_PULL_REQUEST}')[0]" | python) - URL="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls/${CIRCLE_PR_NUMBER}" - MERGE_TO_BRANCH=$(curl -s -X GET -G ${URL} -d access_token=${GITHUB_API_TOKEN} | jq '.base.ref' | tr -d '"') - - git checkout -q ${MERGE_TO_BRANCH} - git reset --hard -q origin/${MERGE_TO_BRANCH} - git checkout -q ${CIRCLE_BRANCH} - echo "checked out ${CIRCLE_BRANCH} from ${MERGE_TO_BRANCH}" - - CHANGED_FILES=$(git diff --name-only ${MERGE_TO_BRANCH}..${CIRCLE_BRANCH} -- '*.py') - echo ${CHANGED_FILES} - echo ${CHANGED_FILES} | xargs --no-run-if-empty flake8 - - - unless: - condition: << parameters.compare-to-pr-root >> - steps: - - run: - name: Find most recent files differing and then flake8 - command: | - if [[ -z "$CIRCLE_PULL_REQUEST" ]]; then - echo "ERROR: No pull request. Must have pull request associated with your code." - exit 1 - fi - - sudo pip install flake8==3.7.7 - - CHANGED_FILES=$(git diff --name-only ${CIRCLE_BRANCH}~1 -- '*.py') - echo ${CHANGED_FILES} - echo ${CHANGED_FILES} | xargs --no-run-if-empty flake8 - -executors: - python-lint: docker: - - image: circleci/python:<< parameters.pyversion >> - working_directory: << parameters.working-directory >> - parameters: - pyversion: &pyversion - default: '3.6' - description: Python version for executor - type: string - working-directory: *working-directory + - image: circleci/python:3.6 + working_directory: ~/qgis-stac-browser resource_class: small - -jobs: - lint: - description: Lint python files - - parameters: - pyversion: *pyversion - working-directory: *working-directory - checkout: *checkout - attach-workspace: *attach-workspace - workspace-root: *workspace-root - compare-to-pr-root: *compare-to-pr-root - - executor: - name: python-lint - pyversion: << parameters.pyversion >> - working-directory: << parameters.working-directory >> - steps: - - lint: - working-directory: <> - checkout: << parameters.checkout >> - attach-workspace: << parameters.attach-workspace >> - workspace-root: << parameters.workspace-root >> - compare-to-pr-root: << parameters.compare-to-pr-root >> + - checkout + - run: + name: Install flake8 + command: sudo pip install flake8==3.7.7 + - run: + name: Run lint + command: flake8 --exclude=help/,venv/ workflows: - build: - jobs: - - lint + build: + jobs: + - lint + version: 2 From b0c8c5587eb3d83f8e2b9e30b38fd09e866b6397 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 13:28:40 -0500 Subject: [PATCH 30/37] Formatted code, added CircleCI linting --- .circleci/config.yml | 2 +- controllers/about_dialog.py | 1 + controllers/add_edit_api_dialog.py | 24 ++-- controllers/collection_loading_dialog.py | 16 +-- controllers/configure_apis_dialog.py | 50 ++++--- controllers/download_selection_dialog.py | 56 ++++---- controllers/downloading_controller.py | 50 ++++--- controllers/item_loading_dialog.py | 11 +- controllers/query_dialog.py | 26 ++-- controllers/results_dialog.py | 48 ++++--- metadata.txt | 39 ----- models/api.py | 61 +++++--- models/collection.py | 6 +- models/item.py | 43 ++++-- models/search_result.py | 1 + plugin_upload.py | 111 -------------- stac_browser.py | 175 ++++++++++++----------- threads/download_items_thread.py | 29 +++- threads/load_api_data_thread.py | 3 +- threads/load_collections_thread.py | 10 +- threads/load_items_thread.py | 11 +- threads/load_preview_thread.py | 7 +- utils/config.py | 44 +++--- utils/fs.py | 9 +- utils/logging.py | 26 +++- utils/network.py | 16 ++- utils/ui.py | 9 +- 27 files changed, 447 insertions(+), 437 deletions(-) delete mode 100644 metadata.txt delete mode 100755 plugin_upload.py diff --git a/.circleci/config.yml b/.circleci/config.yml index f0d1390..017764d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ jobs: command: sudo pip install flake8==3.7.7 - run: name: Run lint - command: flake8 --exclude=help/,venv/ + command: flake8 --exclude=help/,venv/ --per-file-ignores='stac_browser.py:F401,F403' workflows: build: jobs: diff --git a/controllers/about_dialog.py b/controllers/about_dialog.py index 89f292c..93bc71e 100644 --- a/controllers/about_dialog.py +++ b/controllers/about_dialog.py @@ -5,6 +5,7 @@ FORM_CLASS, _ = uic.loadUiType(ui.path('about_dialog.ui')) + class AboutDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, path=None, parent=None, iface=None): super(AboutDialog, self).__init__(parent) diff --git a/controllers/add_edit_api_dialog.py b/controllers/add_edit_api_dialog.py index 1ad75ee..4a6c1e1 100644 --- a/controllers/add_edit_api_dialog.py +++ b/controllers/add_edit_api_dialog.py @@ -1,33 +1,34 @@ import uuid import urllib -from PyQt5 import uic, QtWidgets, QtGui +from PyQt5 import uic, QtWidgets from ..utils import ui -from ..utils.logging import debug, info, warning, error +from ..utils.logging import error from ..threads.load_api_data_thread import LoadAPIDataThread from ..models.api import API FORM_CLASS, _ = uic.loadUiType(ui.path('add_edit_api_dialog.ui')) + class AddEditAPIDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(AddEditAPIDialog, self).__init__(parent) - self.data = data + self.data = data self.hooks = hooks self.iface = iface self.setupUi(self) - + self.populate_details() self.populate_auth_method_combo() - + self.cancelButton.clicked.connect(self.on_cancel_clicked) self.removeButton.clicked.connect(self.on_remove_clicked) self.saveAddButton.clicked.connect(self.on_save_add_clicked) - + def on_cancel_clicked(self): self.reject() @@ -50,8 +51,11 @@ def on_save_add_clicked(self): if self.api is not None: api_id = self.api.id - api = API({'id': api_id, 'href': self.urlEditBox.text()}) - self.loading_thread = LoadAPIDataThread(api, on_error=self.on_api_error, on_finished=self.on_api_success) + api = API({'id': api_id, 'href': self.urlEditBox.text()}) + self.loading_thread = LoadAPIDataThread( + api, + on_error=self.on_api_error, + on_finished=self.on_api_success) self.loading_thread.start() def on_api_error(self, e): @@ -60,7 +64,7 @@ def on_api_error(self, e): self.saveAddButton.setText('Add') else: self.saveAddButton.setText('Save') - + if type(e) == urllib.error.URLError: error(self.iface, f'Connection Failed; {e.reason}') else: @@ -69,7 +73,7 @@ def on_api_error(self, e): def on_api_success(self, api): if self.api is None: self.hooks['add_api'](api) - else: + else: self.hooks['edit_api'](api) self.accept() diff --git a/controllers/collection_loading_dialog.py b/controllers/collection_loading_dialog.py index c2c3e39..4312d95 100644 --- a/controllers/collection_loading_dialog.py +++ b/controllers/collection_loading_dialog.py @@ -4,13 +4,14 @@ from PyQt5 import uic, QtWidgets from ..utils.config import Config -from ..utils.logging import debug, info, warning, error +from ..utils.logging import error from ..utils import ui from ..threads.load_collections_thread import LoadCollectionsThread FORM_CLASS, _ = uic.loadUiType(ui.path('collection_loading_dialog.ui')) + class CollectionLoadingDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(CollectionLoadingDialog, self).__init__(parent) @@ -22,17 +23,17 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): self.setupUi(self) self.setFixedSize(self.size()) + self.loading_thread = LoadCollectionsThread( + Config().apis, + on_progress=self.on_progress_update, + on_error=self.on_error, + on_finished=self.on_loading_finished) - self.loading_thread = LoadCollectionsThread(Config().apis, - on_progress=self.on_progress_update, - on_error=self.on_error, - on_finished=self.on_loading_finished) - self.loading_thread.start() def on_progress_update(self, progress, api): self.label.setText(f'Loading {api}') - self.progressBar.setValue(int(progress*100)) + self.progressBar.setValue(int(progress * 100)) def on_error(self, e, api): if type(e) == urllib.error.URLError: @@ -53,4 +54,3 @@ def closeEvent(self, event): if event.spontaneous(): self.loading_thread.terminate() self.hooks['on_close']() - diff --git a/controllers/configure_apis_dialog.py b/controllers/configure_apis_dialog.py index 84c7255..e168836 100644 --- a/controllers/configure_apis_dialog.py +++ b/controllers/configure_apis_dialog.py @@ -1,19 +1,19 @@ -from PyQt5 import uic, QtWidgets, QtGui +from PyQt5 import uic, QtWidgets from ..utils import ui from ..utils.config import Config -from ..utils.logging import debug, info, warning, error from ..controllers.add_edit_api_dialog import AddEditAPIDialog FORM_CLASS, _ = uic.loadUiType(ui.path('configure_apis_dialog.ui')) + class ConfigureAPIDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(ConfigureAPIDialog, self).__init__(parent) - self.data = data + self.data = data self.hooks = hooks self.iface = iface @@ -32,26 +32,30 @@ def on_close_clicked(self): self.reject() def on_add_api_clicked(self): - dialog = AddEditAPIDialog(data={'api': None}, - hooks={ - "remove_api": self.remove_api, - "add_api": self.add_api, - "edit_api": self.edit_api - }, - parent=self, - iface=self.iface) - result = dialog.exec_() + dialog = AddEditAPIDialog( + data={'api': None}, + hooks={ + "remove_api": self.remove_api, + "add_api": self.add_api, + "edit_api": self.edit_api + }, + parent=self, + iface=self.iface + ) + dialog.exec_() def on_edit_api_clicked(self): - dialog = AddEditAPIDialog(data={'api': self.selected_api}, - hooks={ - "remove_api": self.remove_api, - "add_api": self.add_api, - "edit_api": self.edit_api - }, - parent=self, - iface=self.iface) - result = dialog.exec_() + dialog = AddEditAPIDialog( + data={'api': self.selected_api}, + hooks={ + "remove_api": self.remove_api, + "add_api": self.add_api, + "edit_api": self.edit_api + }, + parent=self, + iface=self.iface + ) + dialog.exec_() def edit_api(self, api): config = Config() @@ -91,7 +95,7 @@ def remove_api(self, api): config.apis = new_apis config.save() - + self.data['apis'] = config.apis self.populate_api_list() self.populate_api_details() @@ -114,7 +118,7 @@ def populate_api_details(self): self.apiDescriptionValue.hide() self.apiEditButton.hide() return - + self.apiUrlValue.setText(self.selected_api.href) self.apiTitleValue.setText(self.selected_api.title) self.apiVersionValue.setText(self.selected_api.version) diff --git a/controllers/download_selection_dialog.py b/controllers/download_selection_dialog.py index ab8587f..8e208be 100644 --- a/controllers/download_selection_dialog.py +++ b/controllers/download_selection_dialog.py @@ -1,26 +1,23 @@ from PyQt5 import uic from PyQt5 import QtWidgets from PyQt5 import QtCore -from PyQt5.QtGui import QStandardItemModel, QStandardItem - -from qgis.core import QgsProject, QgsMapLayer from ..utils import ui -from pprint import pprint FORM_CLASS, _ = uic.loadUiType(ui.path('download_selection_dialog.ui')) + class DownloadSelectionDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(DownloadSelectionDialog, self).__init__(parent) - self.data = data + self.data = data self.hooks = hooks self.iface = iface self.setupUi(self) - + self._current_item_index = 0 self._downloads = [] @@ -34,34 +31,39 @@ def populate_current_item(self): self.nextButton.setText('Download') else: self.nextButton.setText('Next') - collection_label = 'N/A' if self.current_item.collection is not None: collection_label = self.current_item.collection.id self.itemLabel.setText(self.current_item.id) self.collectionLabel.setText(collection_label) - + self.assetListWidget.clear() for asset in sorted(self.current_item.assets): asset_node = QtWidgets.QListWidgetItem(self.assetListWidget) asset_node.setText(f'{asset.pretty_title}') - asset_node.setFlags(asset_node.flags() | QtCore.Qt.ItemIsUserCheckable) + asset_node.setFlags( + asset_node.flags() | QtCore.Qt.ItemIsUserCheckable + ) asset_node.setCheckState(QtCore.Qt.Unchecked) def add_current_item_to_downloads(self): - apply_to_all = (self.applyAllCheckbox.checkState() == QtCore.Qt.Checked) - add_to_layers = (self.addLayersCheckbox.checkState() == QtCore.Qt.Checked) + apply_to_all = ( + self.applyAllCheckbox.checkState() == QtCore.Qt.Checked + ) + add_to_layers = ( + self.addLayersCheckbox.checkState() == QtCore.Qt.Checked + ) stream_cogs = (self.streamCheckbox.checkState() == QtCore.Qt.Checked) download_data = { - 'item': self.current_item, - 'options': { - 'add_to_layers': add_to_layers, - 'stream_cogs': stream_cogs, - 'assets': [a.key for a in self.selected_assets], - }, - } + 'item': self.current_item, + 'options': { + 'add_to_layers': add_to_layers, + 'stream_cogs': stream_cogs, + 'assets': [a.key for a in self.selected_assets], + }, + } self.downloads.append(download_data) @@ -77,13 +79,13 @@ def add_current_item_to_downloads(self): if self.items[i].collection == self.current_item.collection: download_data = { - 'item': self.items[i], - 'options': { - 'add_to_layers': add_to_layers, - 'stream_cogs': stream_cogs, - 'assets': [a.key for a in self.selected_assets], - }, - } + 'item': self.items[i], + 'options': { + 'add_to_layers': add_to_layers, + 'stream_cogs': stream_cogs, + 'assets': [a.key for a in self.selected_assets], + }, + } self.downloads.append(download_data) def item_in_downloads(self, item): @@ -152,7 +154,7 @@ def selected_bands(self): collection_band_list.append({ 'collection': collection, 'bands': selected_bands - }) + }) return collection_band_list @@ -165,7 +167,7 @@ def next_item(self): def on_next_clicked(self): self.add_current_item_to_downloads() - + self._current_item_index += 1 while self.current_item is not None: if not self.item_in_downloads(self.current_item): diff --git a/controllers/downloading_controller.py b/controllers/downloading_controller.py index 5b47bc7..6584574 100644 --- a/controllers/downloading_controller.py +++ b/controllers/downloading_controller.py @@ -2,12 +2,11 @@ import urllib from qgis.core import QgsRasterLayer, QgsProject, Qgis +from PyQt5 import QtCore from qgis.PyQt.QtWidgets import QProgressBar -from qgis.PyQt.QtCore import * -from ..utils import ui -from ..utils.logging import debug, info, warning, error +from ..utils.logging import error from ..threads.download_items_thread import DownloadItemsThread @@ -19,15 +18,16 @@ def __init__(self, data={}, hooks={}, iface=None): self._progress_message_bar = None + self.loading_thread = DownloadItemsThread( + self.downloads, + self.download_directory, + on_progress=self.on_progress_update, + on_gdal_error=self.on_gdal_error, + on_error=self.on_error, + on_add_layer=self.on_add_layer, + on_finished=self.on_downloading_finished + ) - self.loading_thread = DownloadItemsThread(self.downloads, - self.download_directory, - on_progress=self.on_progress_update, - on_gdal_error=self.on_gdal_error, - on_error=self.on_error, - on_add_layer=self.on_add_layer, - on_finished=self.on_downloading_finished) - self.loading_thread.start() @property @@ -47,9 +47,16 @@ def on_error(self, item, e): else: error(self.iface, f'Failed to load {item.id}; {type(e).__name__}') - def on_add_layer(self, current_step, total_steps, item, download_directory): + def on_add_layer(self, current_step, total_steps, item, + download_directory): self.on_progress_update(current_step, total_steps, 'ADDING_TO_LAYERS') - layer = QgsRasterLayer(os.path.join(download_directory, f'{item.id}.vrt'), item.id) + layer = QgsRasterLayer( + os.path.join( + download_directory, + f'{item.id}.vrt' + ), + item.id + ) QgsProject.instance().addMapLayer(layer) def on_destroyed(self, event): @@ -58,17 +65,24 @@ def on_destroyed(self, event): def on_progress_update(self, current_step, total_steps, status): if self._progress_message_bar is None: - self._progress_message_bar = self.iface.messageBar().createMessage(status) + self._progress_message_bar = self.iface.messageBar().createMessage( + status + ) self._progress_message_bar.destroyed.connect(self.on_destroyed) self._progress = QProgressBar() self._progress.setMaximum(total_steps) - self._progress.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._progress.setAlignment( + QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter + ) self._progress_message_bar.layout().addWidget(self._progress) - self.iface.messageBar().pushWidget(self._progress_message_bar, Qgis.Info) + self.iface.messageBar().pushWidget( + self._progress_message_bar, + Qgis.Info + ) else: self._progress_message_bar.setText(status) - - self._progress.setValue(current_step-1) + + self._progress.setValue(current_step - 1) def on_downloading_finished(self): self.iface.messageBar().clearWidgets() diff --git a/controllers/item_loading_dialog.py b/controllers/item_loading_dialog.py index 28ff9be..8f2d851 100644 --- a/controllers/item_loading_dialog.py +++ b/controllers/item_loading_dialog.py @@ -1,14 +1,14 @@ from PyQt5 import uic, QtWidgets import urllib -from ..utils.config import Config from ..utils import ui -from ..utils.logging import debug, info, warning, error +from ..utils.logging import error from ..threads.load_items_thread import LoadItemsThread FORM_CLASS, _ = uic.loadUiType(ui.path('item_loading_dialog.ui')) + class ItemLoadingDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(ItemLoadingDialog, self).__init__(parent) @@ -32,7 +32,11 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): def on_progress(self, api, collections, current_page): collection_label = ', '.join([c.title for c in collections]) - self.loadingLabel.setText(f'Searching {api.title}\nCollections: [{collection_label}]\nPage {current_page}...') + self.loadingLabel.setText( + f'''Searching {api.title}\n + Collections: [{collection_label}]\n + Page {current_page}...''' + ) def on_error(self, e): if type(e) == urllib.error.URLError: @@ -48,4 +52,3 @@ def closeEvent(self, event): if event.spontaneous(): self.loading_thread.terminate() self.hooks['on_close']() - diff --git a/controllers/query_dialog.py b/controllers/query_dialog.py index 8397c03..fafbae5 100644 --- a/controllers/query_dialog.py +++ b/controllers/query_dialog.py @@ -1,22 +1,23 @@ from datetime import datetime from PyQt5 import uic, QtWidgets, QtCore -from PyQt5.QtGui import QStandardItemModel, QStandardItem +from PyQt5.QtGui import QStandardItemModel from PyQt5.QtWidgets import QTreeWidgetItem from qgis.core import QgsProject, QgsMapLayer from ..utils import ui -from ..utils.logging import debug, info, warning, error +from ..utils.logging import error FORM_CLASS, _ = uic.loadUiType(ui.path('query_dialog.ui')) + class QueryDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(QueryDialog, self).__init__(parent) - self.data = data + self.data = data self.hooks = hooks self.iface = iface @@ -38,7 +39,7 @@ def populate_time_periods(self): def populate_extent_layers(self): self._extent_layers = [] - + layers = QgsProject.instance().mapLayers() for layer_key, layer in layers.items(): if layer.type() in [QgsMapLayer.VectorLayer]: @@ -52,15 +53,21 @@ def populate_collection_list(self): for api in self.apis: api_node = QTreeWidgetItem(self.treeView) api_node.setText(0, f'{api.title}') - api_node.setFlags(api_node.flags() | QtCore.Qt.ItemIsTristate | QtCore.Qt.ItemIsUserCheckable) + api_node.setFlags( + api_node.flags() + | QtCore.Qt.ItemIsTristate + | QtCore.Qt.ItemIsUserCheckable + ) api_node.setCheckState(0, QtCore.Qt.Unchecked) for collection in sorted(api.collections): title = collection.title.replace("\n", " ") collection_node = QTreeWidgetItem(api_node) collection_node.setText(0, title) - collection_node.setFlags(collection_node.flags() | QtCore.Qt.ItemIsUserCheckable) + collection_node.setFlags( + collection_node.flags() | QtCore.Qt.ItemIsUserCheckable + ) collection_node.setCheckState(0, QtCore.Qt.Unchecked) - + def validate(self): valid = True if self.extentLayer.currentIndex() < 0: @@ -86,11 +93,12 @@ def api_selections(self): if collection_node.checkState(0) == QtCore.Qt.Checked: selected_collections.append(collection) - if api_node.checkState(0) == QtCore.Qt.Checked or api_node.checkState(0) == QtCore.Qt.PartiallyChecked: + if api_node.checkState(0) == QtCore.Qt.Checked \ + or api_node.checkState(0) == QtCore.Qt.PartiallyChecked: api_collections.append({ 'api': api, 'collections': selected_collections - }) + }) return api_collections @property diff --git a/controllers/results_dialog.py b/controllers/results_dialog.py index 77bc919..b124313 100644 --- a/controllers/results_dialog.py +++ b/controllers/results_dialog.py @@ -3,7 +3,6 @@ from PyQt5 import uic, QtWidgets, QtCore, QtGui from PyQt5.QtWidgets import QFileDialog -from ..models.item import Item from ..utils import ui from ..utils.config import Config from ..threads.load_preview_thread import LoadPreviewThread @@ -11,6 +10,7 @@ FORM_CLASS, _ = uic.loadUiType(ui.path('results_dialog.ui')) + class ResultsDialog(QtWidgets.QDialog, FORM_CLASS): def __init__(self, data={}, hooks={}, parent=None, iface=None): super(ResultsDialog, self).__init__(parent) @@ -27,7 +27,7 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): self.populate_item_list() self.populate_download_directory() - + self.list.activated.connect(self.on_list_clicked) self.selectButton.clicked.connect(self.on_select_all_clicked) self.deselectButton.clicked.connect(self.on_deselect_all_clicked) @@ -37,7 +37,7 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): def populate_item_list(self): self._item_list_model = QtGui.QStandardItemModel(self.list) - + for item in self.items: i = QtGui.QStandardItem(item.id) i.setCheckable(True) @@ -56,9 +56,13 @@ def populate_item_details(self, item): for i, key in enumerate(property_keys): self.propertiesTable.setItem(i, 0, QtWidgets.QTableWidgetItem(key)) - self.propertiesTable.setItem(i, 1, QtWidgets.QTableWidgetItem(str(item.properties[key]))) + self.propertiesTable.setItem( + i, + 1, + QtWidgets.QTableWidgetItem(str(item.properties[key])) + ) self.propertiesTable.resizeColumnsToContents() - + @property def items(self): return sorted(self.data.get('items', [])) @@ -77,13 +81,18 @@ def download_directory(self): return self.downloadDirectory.text() def on_download_clicked(self): - self.hooks['select_downloads'](self.selected_items, self.download_directory) + self.hooks['select_downloads']( + self.selected_items, + self.download_directory + ) def on_download_path_clicked(self): - directory = QFileDialog.getExistingDirectory(self, - "Select Download Directory", - "", - QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks) + directory = QFileDialog.getExistingDirectory( + self, + "Select Download Directory", + "", + QFileDialog.ShowDirsOnly | QFileDialog.DontResolveSymlinks + ) if directory: self._config.download_directory = directory self._config.save() @@ -93,7 +102,7 @@ def on_select_all_clicked(self): for i in range(self._item_list_model.rowCount()): item = self._item_list_model.item(i) item.setCheckState(QtCore.Qt.Checked) - + def on_deselect_all_clicked(self): for i in range(self._item_list_model.rowCount()): item = self._item_list_model.item(i) @@ -128,15 +137,20 @@ def set_preview(self, item, error): if not os.path.exists(item.thumbnail_path): self.imageView.setText('Loading Preview...') - self.loading_thread = LoadPreviewThread(item, on_image_loaded=self.on_image_loaded) + self.loading_thread = LoadPreviewThread( + item, + on_image_loaded=self.on_image_loaded + ) self.loading_thread.start() return image_profile = QtGui.QImage(item.thumbnail_path) - image_profile = image_profile.scaled(self.imageView.size().width(), - self.imageView.size().height(), - aspectRatioMode=QtCore.Qt.KeepAspectRatio, - transformMode=QtCore.Qt.SmoothTransformation) + image_profile = image_profile.scaled( + self.imageView.size().width(), + self.imageView.size().height(), + aspectRatioMode=QtCore.Qt.KeepAspectRatio, + transformMode=QtCore.Qt.SmoothTransformation + ) self.imageView.setPixmap(QtGui.QPixmap.fromImage(image_profile)) def resizeEvent(self, event): @@ -144,7 +158,7 @@ def resizeEvent(self, event): return self.set_preview(self._selected_item) - + def closeEvent(self, event): if event.spontaneous(): self.hooks['on_close']() diff --git a/metadata.txt b/metadata.txt deleted file mode 100644 index 6a80c37..0000000 --- a/metadata.txt +++ /dev/null @@ -1,39 +0,0 @@ -# This file contains metadata for your plugin. Since -# version 2.0 of QGIS this is the proper way to supply -# information about a plugin. The old method of -# embedding metadata in __init__.py will -# is no longer supported since version 2.0. - -# This file should be included when you package your plugin.# Mandatory items: - -[general] -name=STAC Browser -qgisMinimumVersion=3.0 -description=This plugin searches for and downloads assets from STAC catalogs -version=0.1 -author=Kevin Booth -email=kevin@kb.gg - -about=This plugin searches for and downloads assets from STAC catalogs. - -tracker=https://github.com/kbgg/qgis-stac-browser/issues -repository=https://github.com/kbgg/qgis-stac-browser -# End of mandatory metadata - -# Recommended items: - -# Uncomment the following line and add your changelog: -# changelog= - -# Tags are comma separated with spaces allowed -tags=web, raster - -homepage=https://github.com/kbgg/qgis-stac-browser -category=Web -icon=assets/icon.png -# experimental flag -experimental=True - -# deprecated flag (applies to the whole plugin, not just a single version) -deprecated=False - diff --git a/models/api.py b/models/api.py index ba7158c..d9a6790 100644 --- a/models/api.py +++ b/models/api.py @@ -5,21 +5,29 @@ from .search_result import SearchResult from ..utils import network -class API: + +class API: def __init__(self, json=None): self._json = json self._data = self._json.get('data', None) - self._collections = [Collection(self, c) for c in self._json.get('collections', [])] + self._collections = [ + Collection(self, c) for c in self._json.get('collections', []) + ] def load(self): self._data = network.request(f'{self.href}/stac') - self._collections = [self.load_collection(c) for c in self.collection_ids] + self._collections = [ + self.load_collection(c) for c in self.collection_ids + ] def load_collection(self, collection_id): - return Collection(self, network.request(f'{self.href}/collections/{collection_id}')) + return Collection(self, + network.request( + f'{self.href}/collections/{collection_id}')) def search_items(self, collections=[], bbox=[], start_time=None, - end_time=None, page=1, next_page=None, limit=50, on_next_page=None, page_limit=10): + end_time=None, page=1, next_page=None, limit=50, + on_next_page=None, page_limit=10): if page > page_limit: return [] @@ -29,28 +37,35 @@ def search_items(self, collections=[], bbox=[], start_time=None, if end_time is None: time = start_time.strftime('%Y-%m-%dT%H:%M:%SZ') else: - time = f'{start_time.strftime("%Y-%m-%dT%H:%M:%SZ")}/{end_time.strftime("%Y-%m-%dT%H:%M:%SZ")}' + time = f'''{start_time.strftime("%Y-%m-%dT%H:%M:%SZ")} + / + {end_time.strftime("%Y-%m-%dT%H:%M:%SZ")}''' body = { - 'collections': [c.id for c in collections], - 'bbox': bbox, - 'time': time, - 'limit': limit - } + 'collections': [c.id for c in collections], + 'bbox': bbox, + 'time': time, + 'limit': limit + } if next_page is not None: body['next'] = next_page else: body['page'] = page - - search_result = SearchResult(self, network.request(f'{self.href}/stac/search', data=body)) - + + search_result = SearchResult(self, + network.request( + f'{self.href}/stac/search', + data=body)) + items = search_result.items if len(items) >= limit: - items.extend(self.search_items(collections, bbox, start_time, end_time, page+1, search_result.next, limit, on_next_page=on_next_page)) + items.extend(self.search_items(collections, bbox, start_time, + end_time, page + 1, search_result.next, limit, + on_next_page=on_next_page)) return items - + def collection_id_from_href(self, href): p = re.compile('\/collections\/(.*)') m = p.match(urlparse(href).path) @@ -65,11 +80,11 @@ def collection_id_from_href(self, href): @property def json(self): return { - 'id': self.id, - 'href': self.href, - 'data': self.data, - 'collections': [c.json for c in self.collections], - } + 'id': self.id, + 'href': self.href, + 'data': self.data, + 'collections': [c.json for c in self.collections], + } @property def id(self): @@ -115,12 +130,12 @@ def collection_ids(self): continue collection_ids.append(m.groups()[0]) - + return collection_ids @property def collections(self): return self._collections - + def __lt__(self, other): return self.title.lower() < other.title.lower() diff --git a/models/collection.py b/models/collection.py index 6a7a866..f02f7f5 100644 --- a/models/collection.py +++ b/models/collection.py @@ -1,5 +1,6 @@ from .link import Link + class Collection: def __init__(self, api=None, json={}): self._api = api @@ -57,11 +58,11 @@ def links(self): def bands(self): bands = {} for i, band in enumerate(self.properties.get('eo:bands', [])): - band['band'] = i+1 + band['band'] = i + 1 bands[band.get('name', None)] = band return bands - + @property def api(self): return self._pi @@ -102,4 +103,3 @@ def roles(self): @property def url(self): return self._json.get('url', None) - diff --git a/models/item.py b/models/item.py index 6882c6c..0926aa5 100644 --- a/models/item.py +++ b/models/item.py @@ -1,10 +1,10 @@ import os import subprocess import hashlib -import shutil import tempfile -from pathlib import Path from ..utils import network +from ..models.link import Link + class Item: def __init__(self, api=None, json={}): @@ -13,7 +13,10 @@ def __init__(self, api=None, json={}): @property def hashed_id(self): - return hashlib.sha256(f'{self.api.href}/collections/{self.collection.id}/items/{self.id}'.encode('utf-8')).hexdigest() + return hashlib.sha256( + f'{self.api.href}/collections/{self.collection.id}/items/{self.id}' + .encode('utf-8') + ).hexdigest() @property def api(self): @@ -79,7 +82,11 @@ def thumbnail_url(self): @property def temp_dir(self): - temp_dir = os.path.join(tempfile.gettempdir(), 'qgis-stac-browser', self.hashed_id) + temp_dir = os.path.join( + tempfile.gettempdir(), + 'qgis-stac-browser', + self.hashed_id + ) if not os.path.exists(temp_dir): os.makedirs(temp_dir) @@ -94,7 +101,7 @@ def thumbnail_downloaded(self): def download_steps(self, options): steps = 0 - + for asset_key in options.get('assets', []): for asset in self.assets: if asset.key != asset_key: @@ -104,7 +111,7 @@ def download_steps(self, options): continue steps += 1 - + if options.get('add_to_layers', False): steps += 1 return steps @@ -113,22 +120,25 @@ def download(self, gdal_path, options, download_directory, on_update=None): item_download_directory = os.path.join(download_directory, self.id) if not os.path.exists(item_download_directory): os.makedirs(item_download_directory) - + raster_filenames = [] for asset_key in options.get('assets', []): for asset in self.assets: if asset.key != asset_key: continue - + if options.get('stream_cogs', False) and asset.cog is not None: raster_filenames.append(asset.cog) continue if on_update is not None: on_update(f'Downloading {asset.href}') - - temp_filename = os.path.join(item_download_directory, asset.href.split('/')[-1]) + + temp_filename = os.path.join( + item_download_directory, + asset.href.split('/')[-1] + ) if asset.is_raster: raster_filenames.append(temp_filename) network.download(asset.href, temp_filename) @@ -137,13 +147,18 @@ def download(self, gdal_path, options, download_directory, on_update=None): if on_update is not None: on_update(f'Building Virtual Raster...') - arguments = [os.path.join(gdal_path, 'gdalbuildvrt'), '-separate', os.path.join(download_directory, f'{self.id}.vrt')] + arguments = [ + os.path.join(gdal_path, 'gdalbuildvrt'), + '-separate', + os.path.join(download_directory, f'{self.id}.vrt') + ] arguments.extend(raster_filenames) subprocess.run(arguments) def __lt__(self, other): return self.id < other.id + class Asset: def __init__(self, key, json={}, item=None): self._key = key @@ -188,9 +203,9 @@ def type(self): def band(self): if self._item.collection is None: return -1 - + collection_bands = self._item.collection.properties.get('eo:bands', []) - + for i, c in enumerate(collection_bands): if c.get('name', None) == self.key: return i @@ -206,7 +221,7 @@ def __lt__(self, other): if self.band != -1 and other.band == -1: return True - + if self.title is None or other.title is None: return self.key.lower() < other.key.lower() diff --git a/models/search_result.py b/models/search_result.py index ad1d8a3..e659fd5 100644 --- a/models/search_result.py +++ b/models/search_result.py @@ -1,6 +1,7 @@ from .item import Item from .link import Link + class SearchResult: def __init__(self, api=None, json={}): self._api = api diff --git a/plugin_upload.py b/plugin_upload.py deleted file mode 100755 index a88ea2b..0000000 --- a/plugin_upload.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -"""This script uploads a plugin package to the plugin repository. - Authors: A. Pasotti, V. Picavet - git sha : $TemplateVCSFormat -""" - -import sys -import getpass -import xmlrpc.client -from optparse import OptionParser - -standard_library.install_aliases() - -# Configuration -PROTOCOL = 'https' -SERVER = 'plugins.qgis.org' -PORT = '443' -ENDPOINT = '/plugins/RPC2/' -VERBOSE = False - - -def main(parameters, arguments): - """Main entry point. - - :param parameters: Command line parameters. - :param arguments: Command line arguments. - """ - address = "{protocol}://{username}:{password}@{server}:{port}{endpoint}".format( - protocol=PROTOCOL, - username=parameters.username, - password=parameters.password, - server=parameters.server, - port=parameters.port, - endpoint=ENDPOINT) - print("Connecting to: %s" % hide_password(address)) - - server = xmlrpc.client.ServerProxy(address, verbose=VERBOSE) - - try: - with open(arguments[0], 'rb') as handle: - plugin_id, version_id = server.plugin.upload( - xmlrpc.client.Binary(handle.read())) - print("Plugin ID: %s" % plugin_id) - print("Version ID: %s" % version_id) - except xmlrpc.client.ProtocolError as err: - print("A protocol error occurred") - print("URL: %s" % hide_password(err.url, 0)) - print("HTTP/HTTPS headers: %s" % err.headers) - print("Error code: %d" % err.errcode) - print("Error message: %s" % err.errmsg) - except xmlrpc.client.Fault as err: - print("A fault occurred") - print("Fault code: %d" % err.faultCode) - print("Fault string: %s" % err.faultString) - - -def hide_password(url, start=6): - """Returns the http url with password part replaced with '*'. - - :param url: URL to upload the plugin to. - :type url: str - - :param start: Position of start of password. - :type start: int - """ - start_position = url.find(':', start) + 1 - end_position = url.find('@') - return "%s%s%s" % ( - url[:start_position], - '*' * (end_position - start_position), - url[end_position:]) - - -if __name__ == "__main__": - parser = OptionParser(usage="%prog [options] plugin.zip") - parser.add_option( - "-w", "--password", dest="password", - help="Password for plugin site", metavar="******") - parser.add_option( - "-u", "--username", dest="username", - help="Username of plugin site", metavar="user") - parser.add_option( - "-p", "--port", dest="port", - help="Server port to connect to", metavar="80") - parser.add_option( - "-s", "--server", dest="server", - help="Specify server name", metavar="plugins.qgis.org") - options, args = parser.parse_args() - if len(args) != 1: - print("Please specify zip file.\n") - parser.print_help() - sys.exit(1) - if not options.server: - options.server = SERVER - if not options.port: - options.port = PORT - if not options.username: - # interactive mode - username = getpass.getuser() - print("Please enter user name [%s] :" % username, end=' ') - - res = input() - if res != "": - options.username = res - else: - options.username = username - if not options.password: - # interactive mode - options.password = getpass.getpass() - main(options, args) diff --git a/stac_browser.py b/stac_browser.py index 2318cc7..b49a178 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -1,9 +1,8 @@ import time import os.path -from PyQt5.QtCore import QSettings, QCoreApplication from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QAction, QApplication +from PyQt5.QtWidgets import QAction from .resources import * from .controllers.collection_loading_dialog import CollectionLoadingDialog @@ -15,7 +14,7 @@ from .controllers.configure_apis_dialog import ConfigureAPIDialog from .controllers.about_dialog import AboutDialog from .utils.config import Config -from .utils.logging import debug, info, warning, error +from .utils.logging import error class STACBrowser: @@ -28,66 +27,66 @@ def __init__(self, iface): self.menu = u'&STAC Browser' self.current_window = 'COLLECTION_LOADING' - + self.windows = { - 'COLLECTION_LOADING': { - 'class': CollectionLoadingDialog, - 'hooks': { - 'on_finished': self.collection_load_finished, - 'on_close': self.on_close - }, - 'data': None, - 'dialog': None - }, - 'QUERY': { - 'class': QueryDialog, - 'hooks': { - 'on_close': self.on_close, - 'on_search': self.on_search - }, - 'data': None, - 'dialog': None - }, - 'ITEM_LOADING': { - 'class': ItemLoadingDialog, - 'hooks': { - 'on_close': self.on_close, - 'on_finished': self.item_load_finished, - 'on_error': self.results_error - }, - 'data': None, - 'dialog': None - }, - 'RESULTS': { - 'class': ResultsDialog, - 'hooks': { - 'on_close': self.on_close, - 'on_back': self.on_back, - 'on_download': self.on_download, - 'select_downloads': self.select_downloads - }, - 'data': None, - 'dialog': None - }, - } + 'COLLECTION_LOADING': { + 'class': CollectionLoadingDialog, + 'hooks': { + 'on_finished': self.collection_load_finished, + 'on_close': self.on_close + }, + 'data': None, + 'dialog': None + }, + 'QUERY': { + 'class': QueryDialog, + 'hooks': { + 'on_close': self.on_close, + 'on_search': self.on_search + }, + 'data': None, + 'dialog': None + }, + 'ITEM_LOADING': { + 'class': ItemLoadingDialog, + 'hooks': { + 'on_close': self.on_close, + 'on_finished': self.item_load_finished, + 'on_error': self.results_error + }, + 'data': None, + 'dialog': None + }, + 'RESULTS': { + 'class': ResultsDialog, + 'hooks': { + 'on_close': self.on_close, + 'on_back': self.on_back, + 'on_download': self.on_download, + 'select_downloads': self.select_downloads + }, + 'data': None, + 'dialog': None + }, + } def on_search(self, api_collections, extent_layer, time_period): (start_time, end_time) = time_period extent_rect = extent_layer.extent() - extent = [ - extent_rect.xMinimum(), - extent_rect.yMinimum(), - extent_rect.xMaximum(), - extent_rect.yMaximum() - ] + extent = [ + extent_rect.xMinimum(), + extent_rect.yMinimum(), + extent_rect.xMaximum(), + extent_rect.yMaximum() + ] self.windows['ITEM_LOADING']['data'] = { - 'api_collections': api_collections, - 'extent': extent, - 'start_time': start_time, - 'end_time': end_time - } + 'api_collections': api_collections, + 'extent': extent, + 'start_time': start_time, + 'end_time': end_time + } self.current_window = 'ITEM_LOADING' self.windows['QUERY']['dialog'].close() self.load_window() @@ -103,15 +102,17 @@ def on_back(self): def on_close(self): if self.windows is None: return - self.reset_windows() + self.reset_windows() def on_download(self, download_items, download_directory): self._download_controller = DownloadController( - data = { - 'downloads': download_items, - 'download_directory': download_directory, - }, - hooks = {}, iface=self.iface) + data={ + 'downloads': download_items, + 'download_directory': download_directory, + }, + hooks={}, + iface=self.iface + ) self.reset_windows() def downloading_finished(self): @@ -120,7 +121,7 @@ def downloading_finished(self): self.reset_windows() def collection_load_finished(self, apis): - self.windows['QUERY']['data'] = { 'apis': apis } + self.windows['QUERY']['data'] = {'apis': apis} self.current_window = 'QUERY' self.windows['COLLECTION_LOADING']['dialog'].close() self.load_window() @@ -133,7 +134,7 @@ def results_error(self): self.load_window() def item_load_finished(self, items): - self.windows['RESULTS']['data'] = { 'items': items } + self.windows['RESULTS']['data'] = {'items': items} self.current_window = 'RESULTS' self.windows['ITEM_LOADING']['dialog'].close() self.windows['ITEM_LOADING']['data'] = None @@ -141,9 +142,11 @@ def item_load_finished(self, items): self.load_window() def select_downloads(self, items, download_directory): - dialog = DownloadSelectionDialog(data={'items': items}, - hooks={'on_close': self.on_close}, - parent=self.windows['RESULTS']['dialog']) + dialog = DownloadSelectionDialog( + data={'items': items}, + hooks={'on_close': self.on_close}, + parent=self.windows['RESULTS']['dialog'] + ) result = dialog.exec_() @@ -179,27 +182,31 @@ def add_action(self, icon_path, text, callback, enabled_flag=True, def load_window(self): if self.current_window == 'COLLECTION_LOADING': config = Config() - if config.last_update is not None and time.time() - config.last_update < config.api_update_interval: + if config.last_update is not None \ + and time.time() - config.last_update \ + < config.api_update_interval: self.current_window = 'QUERY' - self.windows['QUERY']['data'] = { 'apis': config.apis } + self.windows['QUERY']['data'] = {'apis': config.apis} window = self.windows.get(self.current_window, None) if window is None: - logging.error(f'Window {self.current_window} does not exist') + error(f'Window {self.current_window} does not exist') return if window['dialog'] is None: - window['dialog'] = window.get('class')(data=window.get('data'), - hooks=window.get('hooks'), - parent=self.iface.mainWindow(), - iface=self.iface) + window['dialog'] = window.get('class')( + data=window.get('data'), + hooks=window.get('hooks'), + parent=self.iface.mainWindow(), + iface=self.iface + ) window['dialog'].show() else: window['dialog'].raise_() window['dialog'].show() window['dialog'].activateWindow() - + def reset_windows(self): for key, window in self.windows.items(): if window['dialog'] is not None: @@ -209,15 +216,21 @@ def reset_windows(self): self.current_window = 'COLLECTION_LOADING' def configure_apis(self): - dialog = ConfigureAPIDialog(data={ 'apis': Config().apis }, - hooks={}, - parent=self.iface.mainWindow(), - iface=self.iface) - result = dialog.exec_() + dialog = ConfigureAPIDialog( + data={'apis': Config().apis}, + hooks={}, + parent=self.iface.mainWindow(), + iface=self.iface + ) + dialog.exec_() def about(self): - dialog = AboutDialog(os.path.join(self.plugin_dir, 'about.html'), parent=self.iface.mainWindow(), iface=self.iface) - result = dialog.exec_() + dialog = AboutDialog( + os.path.join(self.plugin_dir, 'about.html'), + parent=self.iface.mainWindow(), + iface=self.iface + ) + dialog.exec_() def initGui(self): icon_path = ':/plugins/stac_browser/assets/icon.png' diff --git a/threads/download_items_thread.py b/threads/download_items_thread.py index 1b92dcc..a05517b 100644 --- a/threads/download_items_thread.py +++ b/threads/download_items_thread.py @@ -4,6 +4,7 @@ from ..models.item import Item from ..utils import fs + class DownloadItemsThread(QThread): progress_signal = pyqtSignal(int, int, str) gdal_error_signal = pyqtSignal(Exception) @@ -11,16 +12,18 @@ class DownloadItemsThread(QThread): add_layer_signal = pyqtSignal(int, int, Item, str) finished_signal = pyqtSignal() - def __init__(self, downloads, download_directory, on_progress=None, on_error=None, on_gdal_error=None, on_add_layer=None, on_finished=None): + def __init__(self, downloads, download_directory, on_progress=None, + on_error=None, on_gdal_error=None, on_add_layer=None, + on_finished=None): QThread.__init__(self) self.downloads = downloads self.download_directory = download_directory - self.on_progress=on_progress + self.on_progress = on_progress self.on_error = on_error self.on_gdal_error = on_gdal_error self.on_add_layer = on_add_layer - self.on_finished=on_finished + self.on_finished = on_finished self._current_item = 0 @@ -44,9 +47,19 @@ def run(self): item = download['item'] options = download['options'] try: - item.download(gdal_path, options, self.download_directory, on_update=self.on_update) + item.download( + gdal_path, + options, + self.download_directory, + on_update=self.on_update + ) if options.get('add_to_layers', False): - self.add_layer_signal.emit(self._current_step, self._total_steps, item, self.download_directory) + self.add_layer_signal.emit( + self._current_step, + self._total_steps, + item, + self.download_directory + ) except URLError as e: self.error_signal.emit(item, e) except socket.timeout as e: @@ -57,4 +70,8 @@ def run(self): def on_update(self, status): self._current_step += 1 - self.progress_signal.emit(self._current_step, self._total_steps, f'[{self._current_item + 1}/{len(self.downloads)}] {status}') + self.progress_signal.emit( + self._current_step, + self._total_steps, + f'[{self._current_item + 1}/{len(self.downloads)}] {status}' + ) diff --git a/threads/load_api_data_thread.py b/threads/load_api_data_thread.py index e80d2b4..849b30b 100644 --- a/threads/load_api_data_thread.py +++ b/threads/load_api_data_thread.py @@ -3,6 +3,7 @@ from urllib.error import URLError from ..models.api import API + class LoadAPIDataThread(QThread): error_signal = pyqtSignal(Exception) finished_signal = pyqtSignal(API) @@ -13,7 +14,7 @@ def __init__(self, api, on_error=None, on_finished=None): self.on_error = on_error self.on_finished = on_finished - + self.error_signal.connect(self.on_error) self.finished_signal.connect(self.on_finished) diff --git a/threads/load_collections_thread.py b/threads/load_collections_thread.py index 107d032..abdec82 100644 --- a/threads/load_collections_thread.py +++ b/threads/load_collections_thread.py @@ -3,18 +3,20 @@ from urllib.error import URLError from ..models.api import API + class LoadCollectionsThread(QThread): progress_signal = pyqtSignal(float, str) error_signal = pyqtSignal(Exception, API) finished_signal = pyqtSignal(list) - def __init__(self, api_list, on_progress=None, on_error=None, on_finished=None): + def __init__(self, api_list, on_progress=None, on_error=None, + on_finished=None): QThread.__init__(self) self.api_list = api_list - self.on_progress=on_progress + self.on_progress = on_progress self.on_error = on_error - self.on_finished=on_finished + self.on_finished = on_finished self.progress_signal.connect(self.on_progress) self.error_signal.connect(self.on_error) @@ -32,5 +34,5 @@ def run(self): self.error_signal.emit(e, api) except socket.timeout as e: self.error_signal.emit(e, api) - + self.finished_signal.emit(apis) diff --git a/threads/load_items_thread.py b/threads/load_items_thread.py index 985e99f..22a631f 100644 --- a/threads/load_items_thread.py +++ b/threads/load_items_thread.py @@ -3,6 +3,7 @@ from urllib.error import URLError from ..models.api import API + class LoadItemsThread(QThread): progress_signal = pyqtSignal(API, list, int) error_signal = pyqtSignal(Exception) @@ -21,7 +22,7 @@ def __init__(self, api_collections, extent, start_time, end_time, self.on_error = on_error self.on_finished = on_finished self._current_collections = [] - + self.progress_signal.connect(self.on_progress) self.error_signal.connect(self.on_error) self.finished_signal.connect(self.on_finished) @@ -34,7 +35,7 @@ def run(self): api = api_collection['api'] collections = api_collection['collections'] self._current_collections = collections - + items = api.search_items(collections, self.extent, self.start_time, @@ -49,4 +50,8 @@ def run(self): def on_next_page(self, api): self.current_page += 1 - self.progress_signal.emit(api, self._current_collections, self.current_page) + self.progress_signal.emit( + api, + self._current_collections, + self.current_page + ) diff --git a/threads/load_preview_thread.py b/threads/load_preview_thread.py index 0691f98..a074c22 100644 --- a/threads/load_preview_thread.py +++ b/threads/load_preview_thread.py @@ -4,13 +4,14 @@ from ..utils import network from ..models.item import Item + class LoadPreviewThread(QThread): finished_signal = pyqtSignal(Item, bool) def __init__(self, item, on_image_loaded=None): QThread.__init__(self) self.item = item - self.on_image_loaded=on_image_loaded + self.on_image_loaded = on_image_loaded self.finished_signal.connect(self.on_image_loaded) @@ -18,7 +19,7 @@ def run(self): try: network.download(self.item.thumbnail_url, self.item.thumbnail_path) self.finished_signal.emit(self.item, False) - except URLError as e: + except URLError: self.finished_signal.emit(self.item, True) - except socket.timeout as e: + except socket.timeout: self.finished_signal.emit(self.item, True) diff --git a/utils/config.py b/utils/config.py index 9f5ae4c..28634ce 100644 --- a/utils/config.py +++ b/utils/config.py @@ -2,6 +2,7 @@ import json from ..models.api import API + class Config: def __init__(self): self._json = None @@ -17,17 +18,20 @@ def load(self): def save(self): config = { - 'apis': [api.json for api in self.apis], - 'download_directory': self.download_directory, - 'last_update': self.last_update, - 'api_update_interval': self.api_update_interval - } + 'apis': [api.json for api in self.apis], + 'download_directory': self.download_directory, + 'last_update': self.last_update, + 'api_update_interval': self.api_update_interval + } with open(self.path, 'w') as f: f.write(json.dumps(config)) @property def path(self): - return os.path.join(os.path.split(os.path.dirname(__file__))[0], 'config.json') + return os.path.join( + os.path.split(os.path.dirname(__file__))[0], + 'config.json' + ) @property def apis(self): @@ -35,19 +39,19 @@ def apis(self): if apis is None: apis = [ - { - "id": "default-staccato", - "href": "https://stac.boundlessgeo.io", - }, - { - "id": "default-sat-api", - "href": "https://sat-api.developmentseed.org", - }, - { - "id": "default-astraea", - "href": "https://stac.astraea.earth/api/v2", - } - ] + { + "id": "default-staccato", + "href": "https://stac.boundlessgeo.io", + }, + { + "id": "default-sat-api", + "href": "https://sat-api.developmentseed.org", + }, + { + "id": "default-astraea", + "href": "https://stac.astraea.earth/api/v2", + } + ] return [API(api) for api in apis] @@ -61,7 +65,7 @@ def last_update(self): @property def api_update_interval(self): - return self._json.get('api_update_interval', 60*60*24) + return self._json.get('api_update_interval', 60 * 60 * 24) @last_update.setter def last_update(self, value): diff --git a/utils/fs.py b/utils/fs.py index bd10e71..b57a60c 100644 --- a/utils/fs.py +++ b/utils/fs.py @@ -1,6 +1,7 @@ import os import subprocess + def gdal_path(): common_paths = [ '', @@ -10,10 +11,12 @@ def gdal_path(): for common_path in common_paths: try: - subprocess.run([os.path.join(common_path, 'gdalbuildvrt'), '--version']) + subprocess.run([ + os.path.join(common_path, 'gdalbuildvrt'), + '--version' + ]) return common_path - except FileNotFoundError as e: + except FileNotFoundError: continue return None - diff --git a/utils/logging.py b/utils/logging.py index 7966bea..a292db3 100644 --- a/utils/logging.py +++ b/utils/logging.py @@ -1,17 +1,35 @@ from qgis.core import QgsMessageLog, Qgis + def debug(message): QgsMessageLog.logMessage(message, level=Qgis.Info) + def info(iface, message, duration=5): QgsMessageLog.logMessage(message, level=Qgis.Info) - iface.messageBar().pushMessage("Info", message, level=Qgis.Info, duration=duration) + iface.messageBar().pushMessage( + "Info", + message, + level=Qgis.Info, + duration=duration + ) + def warning(iface, message, duration=5): QgsMessageLog.logMessage(message, level=Qgis.Warning) - iface.messageBar().pushMessage("Warning", message, level=Qgis.Warning, duration=duration) + iface.messageBar().pushMessage( + "Warning", + message, + level=Qgis.Warning, + duration=duration + ) + def error(iface, message, duration=5): QgsMessageLog.logMessage(message, level=Qgis.Critical) - iface.messageBar().pushMessage("Error", message, level=Qgis.Critical, duration=duration) - + iface.messageBar().pushMessage( + "Error", + message, + level=Qgis.Critical, + duration=duration + ) diff --git a/utils/network.py b/utils/network.py index 6694feb..4993cd5 100644 --- a/utils/network.py +++ b/utils/network.py @@ -4,23 +4,35 @@ import json import os + def ssl_context(): if os.environ.get('STAC_DEBUG', False): return ssl._create_unverified_context() return ssl.SSLContext() + def request(url, data=None): r = urllib.request.Request(url) if data is not None: body_bytes = json.dumps(data).encode('utf-8') r.add_header('Content-Type', 'application/json; charset=utf-8') r.add_header('Content-Length', len(body_bytes)) - r = urllib.request.urlopen(r, body_bytes, context=ssl_context(), timeout=5) + r = urllib.request.urlopen( + r, + body_bytes, + context=ssl_context(), + timeout=5 + ) else: r = urllib.request.urlopen(r, context=ssl_context(), timeout=5) return json.loads(r.read()) + def download(url, path): - with urllib.request.urlopen(url, context=ssl_context(), timeout=5) as response, open(path, 'wb') as f: + with urllib.request.urlopen( + url, + context=ssl_context(), + timeout=5) as response, \ + open(path, 'wb') as f: shutil.copyfileobj(response, f) diff --git a/utils/ui.py b/utils/ui.py index 2271ed5..b0245d8 100644 --- a/utils/ui.py +++ b/utils/ui.py @@ -1,6 +1,9 @@ import os + def path(filename): - return os.path.join(os.path.split(os.path.dirname(__file__))[0], - 'views', - filename) + return os.path.join( + os.path.split(os.path.dirname(__file__))[0], + 'views', + filename + ) From 8bc974fb13c31f5bb8ef938544b1fe364badf703 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 13:30:46 -0500 Subject: [PATCH 31/37] Fixed warning for regex string --- models/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/api.py b/models/api.py index d9a6790..483c2bf 100644 --- a/models/api.py +++ b/models/api.py @@ -67,7 +67,7 @@ def search_items(self, collections=[], bbox=[], start_time=None, return items def collection_id_from_href(self, href): - p = re.compile('\/collections\/(.*)') + p = re.compile(r'\/collections\/(.*)') m = p.match(urlparse(href).path) if m is None: return None @@ -119,7 +119,7 @@ def links(self): @property def collection_ids(self): collection_ids = [] - p = re.compile('\/collections\/(.*)') + p = re.compile(r'\/collections\/(.*)') for link in self.links: m = p.match(urlparse(link.href).path) From 7f41e07dba188eb60bea58a612cd1dc47e1b4fc7 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 13:42:37 -0500 Subject: [PATCH 32/37] Fixed multi line string issues --- controllers/item_loading_dialog.py | 10 +++++----- models/api.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/controllers/item_loading_dialog.py b/controllers/item_loading_dialog.py index 8f2d851..0602d8c 100644 --- a/controllers/item_loading_dialog.py +++ b/controllers/item_loading_dialog.py @@ -32,11 +32,11 @@ def __init__(self, data={}, hooks={}, parent=None, iface=None): def on_progress(self, api, collections, current_page): collection_label = ', '.join([c.title for c in collections]) - self.loadingLabel.setText( - f'''Searching {api.title}\n - Collections: [{collection_label}]\n - Page {current_page}...''' - ) + self.loadingLabel.setText('\n'.join(( + f'Searching {api.title}', + f'Collections: [{collection_label}]', + f'Page {current_page}...' + ))) def on_error(self, e): if type(e) == urllib.error.URLError: diff --git a/models/api.py b/models/api.py index 483c2bf..13c357d 100644 --- a/models/api.py +++ b/models/api.py @@ -37,9 +37,9 @@ def search_items(self, collections=[], bbox=[], start_time=None, if end_time is None: time = start_time.strftime('%Y-%m-%dT%H:%M:%SZ') else: - time = f'''{start_time.strftime("%Y-%m-%dT%H:%M:%SZ")} - / - {end_time.strftime("%Y-%m-%dT%H:%M:%SZ")}''' + start = start_time.strftime('%Y-%m-%dT%H:%M:%SZ') + end = end_time.strftime('%Y-%m-%dT%H:%M:%SZ') + time = f'{start}/{end}' body = { 'collections': [c.id for c in collections], From 3364303e038ed68ec9a46af9a0fe7be289f16c03 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 13:55:49 -0500 Subject: [PATCH 33/37] Check python version --- stac_browser.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/stac_browser.py b/stac_browser.py index b49a178..c9e1cde 100644 --- a/stac_browser.py +++ b/stac_browser.py @@ -1,5 +1,6 @@ import time import os.path +import sys from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QAction @@ -180,6 +181,9 @@ def add_action(self, icon_path, text, callback, enabled_flag=True, return action def load_window(self): + correct_version = self.check_version() + if not correct_version: + return if self.current_window == 'COLLECTION_LOADING': config = Config() if config.last_update is not None \ @@ -191,7 +195,7 @@ def load_window(self): window = self.windows.get(self.current_window, None) if window is None: - error(f'Window {self.current_window} does not exist') + error(self.iface, f'Window {self.current_window} does not exist') return if window['dialog'] is None: @@ -215,7 +219,27 @@ def reset_windows(self): window['dialog'] = None self.current_window = 'COLLECTION_LOADING' + def check_version(self): + if sys.version_info < (3, 6): + v = '.'.join(( + str(sys.version_info.major), + str(sys.version_info.minor), + str(sys.version_info.micro) + )) + error( + self.iface, + ''.join(( + 'This plugin requires Python >= 3.6; ', + f'You are running {v}' + )) + ) + return False + return True + def configure_apis(self): + correct_version = self.check_version() + if not correct_version: + return dialog = ConfigureAPIDialog( data={'apis': Config().apis}, hooks={}, From 0166e9a48e4a450659c6bba1113a45b99091b9c7 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 14:06:31 -0500 Subject: [PATCH 34/37] Check if progress bar closed and don't try to update progress --- controllers/downloading_controller.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/controllers/downloading_controller.py b/controllers/downloading_controller.py index 6584574..40b24e7 100644 --- a/controllers/downloading_controller.py +++ b/controllers/downloading_controller.py @@ -17,6 +17,7 @@ def __init__(self, data={}, hooks={}, iface=None): self.iface = iface self._progress_message_bar = None + self._loading_closed = False self.loading_thread = DownloadItemsThread( self.downloads, @@ -60,10 +61,13 @@ def on_add_layer(self, current_step, total_steps, item, QgsProject.instance().addMapLayer(layer) def on_destroyed(self, event): + self._loading_closed = True if not self.loading_thread.isFinished: self.loading_thread.terminate() def on_progress_update(self, current_step, total_steps, status): + if self._loading_closed: + return if self._progress_message_bar is None: self._progress_message_bar = self.iface.messageBar().createMessage( status From d3ed32c5183e53c2387d420d59610a287adb51b3 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 14:15:45 -0500 Subject: [PATCH 35/37] Added metadata --- metadata.txt | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 metadata.txt diff --git a/metadata.txt b/metadata.txt new file mode 100644 index 0000000..b4832c0 --- /dev/null +++ b/metadata.txt @@ -0,0 +1,40 @@ +# This file contains metadata for your plugin. Since +# version 2.0 of QGIS this is the proper way to supply +# information about a plugin. The old method of +# embedding metadata in __init__.py will +# is no longer supported since version 2.0. + +# This file should be included when you package your plugin.# Mandatory items: + +[general] +name=STAC Browser +qgisMinimumVersion=3.0 +description=This plugin searches for and downloads assets from STAC catalogs +version=1.0.0 +author=Kevin Booth +email=kevin@kb.gg + +about=This plugin searches for and downloads assets from STAC catalogs. + +tracker=https://github.com/kbgg/qgis-stac-browser/issues +repository=https://github.com/kbgg/qgis-stac-browser +# End of mandatory metadata + +# Recommended items: + +# Uncomment the following line and add your changelog: +# changelog= + +# Tags are comma separated with spaces allowed +tags=web, raster + +homepage=https://github.com/kbgg/qgis-stac-browser +category=Web +icon=assets/icon.png +# experimental flag +experimental=True + +# deprecated flag (applies to the whole plugin, not just a single version) +deprecated=False + + From 9392f6869f6e41290a9a641ef1a707a609a67c92 Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 16:05:41 -0500 Subject: [PATCH 36/37] Added release artifact building --- .circleci/config.yml | 25 +++++++++++++++++++++++++ pb_tool.cfg | 39 +-------------------------------------- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 017764d..00cf233 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,6 +13,31 @@ jobs: - run: name: Run lint command: flake8 --exclude=help/,venv/ --per-file-ignores='stac_browser.py:F401,F403' + build: + docker: + - image: circleci/python:3.6 + working_directory: ~/qgis-stac-browser + resource_class: small + steps: + - checkout + - run: + name: Install pb_tool and PyQT5 + command: sudo pip install pb_tool pyqt5 + - run: + name: Make plugin directory + command: mkdir /home/circleci/plugin + - run: + name: Update pb_tool.cfg + command: "sed -i -e 's/plugin_path:/plugin_path: \\/home\\/circleci\\/plugin/' pb_tool.cfg" + - run: + name: Deploy Plugin + command: pb_tool deploy -y + - run: + name: Build Zip + command: pb_tool zip --quick + - store_artifacts: + path: ~/qgis-stac-browser/stac_browser.zip + destination: stac_browser.zip workflows: build: jobs: diff --git a/pb_tool.cfg b/pb_tool.cfg index c527548..9e07a1b 100644 --- a/pb_tool.cfg +++ b/pb_tool.cfg @@ -1,41 +1,3 @@ -#/*************************************************************************** -# STACBrowser -# -# Configuration file for plugin builder tool (pb_tool) -# Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/ -# ------------------- -# begin : 2019-05-28 -# copyright : (C) 2019 by Kevin Booth -# email : kevin@kb.gg -# ***************************************************************************/ -# -#/*************************************************************************** -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU General Public License as published by * -# * the Free Software Foundation; either version 2 of the License, or * -# * (at your option) any later version. * -# * * -# ***************************************************************************/ -# -# -# You can install pb_tool using: -# pip install http://geoapt.net/files/pb_tool.zip -# -# Consider doing your development (and install of pb_tool) in a virtualenv. -# -# For details on setting up and using pb_tool, see: -# http://g-sherman.github.io/plugin_build_tool/ -# -# Issues and pull requests here: -# https://github.com/g-sherman/plugin_build_tool: -# -# Sane defaults for your plugin generated by the Plugin Builder are -# already set below. -# -# As you add Python source files and UI files to your plugin, add -# them to the appropriate [files] section below. - [plugin] # Name of the plugin. This is the name of the directory that will # be created in .qgis2/python/plugins @@ -75,3 +37,4 @@ locales: dir: help/build/html # the name of the directory to target in the deployed plugin target: help + From 2e461d1f7aec1a81a84b79ea08706e320e3849ad Mon Sep 17 00:00:00 2001 From: Kevin Booth Date: Mon, 24 Jun 2019 16:07:06 -0500 Subject: [PATCH 37/37] Added build to workflow --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 00cf233..8f7e9ca 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,4 +42,5 @@ workflows: build: jobs: - lint + - build version: 2