Browse Source

jupyter: Add missing mapset, lazy imports, tests for displays (#1739)

* Fix bug in re-projecting: Add mapset parameter to v.proj and r.proj.
* Lazy-import of folium.
* Lazy-import of IPython.
* Separate tests for InteractiveMap and GrassRenderer.
* skipIf decorators in case folium or IPython are missing.
Caitlin H 3 years ago
parent
commit
97233d6f3e

+ 2 - 1
python/grass/jupyter/display.py

@@ -13,7 +13,6 @@
 
 import os
 import shutil
-from IPython.display import Image
 import tempfile
 import grass.script as gs
 
@@ -121,4 +120,6 @@ class GrassRenderer:
 
     def show(self):
         """Displays a PNG image of the map"""
+        from IPython.display import Image
+
         return Image(self._filename)

+ 23 - 13
python/grass/jupyter/interact_display.py

@@ -15,7 +15,6 @@ import sys
 import tempfile
 import weakref
 from pathlib import Path
-import folium
 import grass.script as gs
 from .display import GrassRenderer
 from .utils import (
@@ -45,6 +44,10 @@ class InteractiveMap:
         :param int width: width in pixels of figure (default 400)
         """
 
+        import folium
+
+        self._folium = folium
+
         # Store height and width
         self.width = width
         self.height = height
@@ -70,7 +73,7 @@ class InteractiveMap:
         center = (float(center["center_northing"]), float(center["center_easting"]))
 
         # Create Folium Map
-        self.map = folium.Map(
+        self.map = self._folium.Map(
             width=self.width,
             height=self.height,
             location=center,
@@ -109,26 +112,30 @@ class InteractiveMap:
         file_info = gs.find_file(name, element="vector")
         full_name = file_info["fullname"]
         name = file_info["name"]
+        mapset = file_info["mapset"]
+        new_name = full_name.replace("@", "_")
         # Reproject vector into WGS84 Location
         env_info = gs.gisenv(env=self._src_env)
         gs.run_command(
             "v.proj",
-            input=full_name,
+            input=name,
+            output=new_name,
+            mapset=mapset,
             location=env_info["LOCATION_NAME"],
             dbase=env_info["GISDBASE"],
             env=self._wgs84_env,
         )
         # Convert to GeoJSON
-        json_file = Path(self._tmp_dir.name) / f"tmp_{name}.json"
+        json_file = Path(self._tmp_dir.name) / f"{new_name}.json"
         gs.run_command(
             "v.out.ogr",
-            input=name,
+            input=new_name,
             output=json_file,
             format="GeoJSON",
             env=self._wgs84_env,
         )
         # Import GeoJSON to folium and add to map
-        folium.GeoJson(str(json_file), name=name).add_to(self.map)
+        self._folium.GeoJson(str(json_file), name=name).add_to(self.map)
 
     def add_raster(self, name, opacity=0.8):
         """Imports raster into temporary WGS84 location,
@@ -140,16 +147,18 @@ class InteractiveMap:
         """
 
         # Find full name of raster
-        file_info = gs.find_file(name, element="cell")
+        file_info = gs.find_file(name, element="cell", env=self._src_env)
         full_name = file_info["fullname"]
         name = file_info["name"]
+        mapset = file_info["mapset"]
 
         # Reproject raster into WGS84/epsg3857 location
         env_info = gs.gisenv(env=self._src_env)
         resolution = estimate_resolution(
-            raster=full_name,
-            dbase=env_info["GISDBASE"],
+            raster=name,
+            mapset=mapset,
             location=env_info["LOCATION_NAME"],
+            dbase=env_info["GISDBASE"],
             env=self._psmerc_env,
         )
         tgt_name = full_name.replace("@", "_")
@@ -157,6 +166,7 @@ class InteractiveMap:
             "r.proj",
             input=full_name,
             output=tgt_name,
+            mapset=mapset,
             location=env_info["LOCATION_NAME"],
             dbase=env_info["GISDBASE"],
             resolution=resolution,
@@ -187,7 +197,7 @@ class InteractiveMap:
         ]
 
         # Overlay image on folium map
-        img = folium.raster_layers.ImageOverlay(
+        img = self._folium.raster_layers.ImageOverlay(
             image=filename,
             name=name,
             bounds=new_bounds,
@@ -201,10 +211,10 @@ class InteractiveMap:
     def add_layer_control(self, **kwargs):
         """Add layer control to display"""
         self.layer_control = True
-        self.layer_control_object = folium.LayerControl(**kwargs)
+        self.layer_control_object = self._folium.LayerControl(**kwargs)
 
     def show(self):
-        """This function creates a folium map with a GRASS raster
+        """This function returns a folium figure object with a GRASS raster
         overlayed on a basemap.
 
         If map has layer control enabled, additional layers cannot be
@@ -213,7 +223,7 @@ class InteractiveMap:
         if self.layer_control:
             self.map.add_child(self.layer_control_object)
         # Create Figure
-        fig = folium.Figure(width=self.width, height=self.height)
+        fig = self._folium.Figure(width=self.width, height=self.height)
         # Add map to figure
         fig.add_child(self.map)
 

+ 145 - 0
python/grass/jupyter/testsuite/grassrenderer_test.py

@@ -0,0 +1,145 @@
+#!/usr/bin/env python
+
+############################################################################
+#
+# NAME:      grassrenderer_test.py
+#
+# AUTHOR:    Caitlin Haedrich (caitlin dot haedrich gmail com)
+#
+# PURPOSE:   This is a test script for grass.jupyter's GrassRenderer
+#
+# COPYRIGHT: (C) 2021 by Caitlin Haedrich and the GRASS Development Team
+#
+#            This program is free software under the GNU General Public
+#            License (>=v2). Read the file COPYING that comes with GRASS
+#            for details.
+#
+#############################################################################
+
+import os
+import unittest
+import sys
+from pathlib import Path
+import grass.jupyter as gj
+from grass.gunittest.case import TestCase
+from grass.gunittest.main import test
+
+
+def can_import_ipython():
+    """Test folium import to see if test can be run."""
+    try:
+        import IPython
+
+        return True
+    except ImportError:
+        return False
+
+
+class TestDisplay(TestCase):
+    # Setup variables
+    files = []
+
+    @classmethod
+    def setUpClass(cls):
+        """Ensures expected computational region"""
+        # to not override mapset's region (which might be used by other tests)
+        cls.use_temp_region()
+        # cls.runModule or self.runModule is used for general module calls
+        # we'll use the elevation raster as a test display
+        cls.runModule("g.region", raster="elevation")
+
+    @classmethod
+    def tearDownClass(cls):
+        """Remove temporary region"""
+        cls.del_temp_region()
+
+    def tearDown(self):
+        """
+        Remove the PNG file created after testing with "filename =" option.
+        This is executed after each test run.
+        """
+        for f in self.files:
+            f = Path(f)
+            if sys.version_info < (3, 8):
+                try:
+                    os.remove(f)
+                except FileNotFoundError:
+                    pass
+            else:
+                f.unlink(missing_ok=True)
+
+    def test_defaults(self):
+        """Test that GrassRenderer can create a map with default settings."""
+        # Create a map with default inputs
+        grass_renderer = gj.GrassRenderer()
+        # Adding vectors and rasters to the map
+        grass_renderer.run("d.rast", map="elevation")
+        grass_renderer.run("d.vect", map="roadsmajor")
+        # Make sure image was created
+        self.assertFileExists(grass_renderer._filename)
+
+    def test_filename(self):
+        """Test that GrassRenderer creates maps with unique filenames."""
+        # Create map with unique filename
+        custom_filename = "test_filename.png"
+        grass_renderer = gj.GrassRenderer(filename=custom_filename)
+        # Add files to self for cleanup later
+        self.files.append(custom_filename)
+        self.files.append(f"{custom_filename}.grass_vector_legend")
+        # Add a vector and a raster to the map
+        grass_renderer.run("d.rast", map="elevation")
+        grass_renderer.run("d.vect", map="roadsmajor")
+        # Make sure image was created
+        self.assertFileExists(custom_filename)
+
+    def test_hw(self):
+        """Test that GrassRenderer creates maps with custom height and widths."""
+        # Create map with height and width parameters
+        grass_renderer = gj.GrassRenderer(width=400, height=400)
+        # Add just a vector (for variety here)
+        grass_renderer.run("d.vect", map="roadsmajor")
+
+    def test_env(self):
+        """Test that we can hand an environment to GrassRenderer."""
+        # Create map with environment parameter
+        grass_renderer = gj.GrassRenderer(env=os.environ.copy())
+        # Add just a raster (again for variety)
+        grass_renderer.run("d.rast", map="elevation")
+
+    def test_text(self):
+        """Test that we can set a unique text_size in GrassRenderer."""
+        # Create map with unique text_size parameter
+        grass_renderer = gj.GrassRenderer(text_size=10)
+        grass_renderer.run("d.vect", map="roadsmajor")
+        grass_renderer.run("d.rast", map="elevation")
+
+    def test_shortcut(self):
+        """Test that we can use display shortcuts with __getattr__."""
+        # Create map
+        grass_renderer = gj.GrassRenderer()
+        # Use shortcut
+        grass_renderer.d_rast(map="elevation")
+        grass_renderer.d_vect(map="roadsmajor")
+
+    def test_shortcut_error(self):
+        """Test that passing an incorrect attribute raises
+        appropriate error"""
+        # Create map
+        grass_renderer = gj.GrassRenderer()
+        # Pass bad shortcuts
+        with self.assertRaisesRegex(AttributeError, "Module must begin with 'd_'"):
+            grass_renderer.r_watersheds()
+        with self.assertRaisesRegex(AttributeError, "d.module.does.not.exist"):
+            grass_renderer.d_module_does_not_exist()
+
+    @unittest.skipIf(not can_import_ipython(), "Cannot import IPython")
+    def test_image_creation(self):
+        """Test that show() returns an image object."""
+        # Create map
+        grass_renderer = gj.GrassRenderer()
+        grass_renderer.d_rast(map="elevation")
+        self.assertTrue(grass_renderer.show(), "Failed to open PNG image")
+
+
+if __name__ == "__main__":
+    test()

+ 92 - 0
python/grass/jupyter/testsuite/interactivemap_test.py

@@ -0,0 +1,92 @@
+#!/usr/bin/env python
+
+############################################################################
+#
+# NAME:      interactivemap_test.py
+#
+# AUTHOR:    Caitlin Haedrich (caitlin dot haedrich gmail com)
+#
+# PURPOSE:   This is a test script for grass.jupyter's InteractiveMap
+#
+# COPYRIGHT: (C) 2021 by Caitlin Haedrich and the GRASS Development Team
+#
+#            This program is free software under the GNU General Public
+#            License (>=v2). Read the file COPYING that comes with GRASS
+#            for details.
+#
+#############################################################################
+
+
+import os
+import unittest
+import sys
+from pathlib import Path
+import grass.jupyter as gj
+from grass.gunittest.case import TestCase
+from grass.gunittest.main import test
+
+
+def can_import_folium():
+    """Test folium import to see if test can be run."""
+    try:
+        import folium
+
+        return True
+    except ImportError:
+        return False
+
+
+class TestDisplay(TestCase):
+    # Setup variables
+    files = []
+
+    @classmethod
+    def setUpClass(cls):
+        """Ensures expected computational region"""
+        # to not override mapset's region (which might be used by other tests)
+        cls.use_temp_region()
+        # cls.runModule or self.runModule is used for general module calls
+        # we'll use the elevation raster as a test display
+        cls.runModule("g.region", raster="elevation")
+
+    @classmethod
+    def tearDownClass(cls):
+        """Remove temporary region"""
+        cls.del_temp_region()
+
+    def tearDown(self):
+        """
+        Remove the PNG file created after testing with "filename =" option.
+        This is executed after each test run.
+        """
+        for f in self.files:
+            f = Path(f)
+            if sys.version_info < (3, 8):
+                try:
+                    os.remove(f)
+                except FileNotFoundError:
+                    pass
+            else:
+                f.unlink(missing_ok=True)
+
+    @unittest.skipIf(not can_import_folium(), "Cannot import folium")
+    def test_basic(self):
+        # Create InteractiveMap
+        interactive_map = gj.InteractiveMap()
+        interactive_map.add_raster("elevation")
+        interactive_map.add_vector("roadsmajor")
+        interactive_map.show()
+
+    @unittest.skipIf(not can_import_folium(), "Cannot import folium")
+    def test_save_as_html(self):
+        # Create InteractiveMap
+        interactive_map = gj.InteractiveMap()
+        interactive_map.add_vector("roadsmajor")
+        filename = "InteractiveMap_test.html"
+        self.files.append(filename)
+        interactive_map.save(filename)
+        self.assertFileExists(filename)
+
+
+if __name__ == "__main__":
+    test()

+ 10 - 3
python/grass/jupyter/utils.py

@@ -71,19 +71,26 @@ def reproject_region(region, from_proj, to_proj):
     return region
 
 
-def estimate_resolution(raster, dbase, location, env):
+def estimate_resolution(raster, mapset, location, dbase, env):
     """Estimates resolution of reprojected raster.
 
     :param str raster: name of raster
-    :param str dbase: path to source database
+    :param str mapset: mapset of raster
     :param str location: name of source location
+    :param str dbase: path to source database
     :param dict env: target environment
 
     :return float estimate: estimated resolution of raster in destination
                             environment
     """
     output = gs.read_command(
-        "r.proj", flags="g", input=raster, dbase=dbase, location=location, env=env
+        "r.proj",
+        flags="g",
+        input=raster,
+        mapset=mapset,
+        location=location,
+        dbase=dbase,
+        env=env,
     ).strip()
     params = gs.parse_key_val(output, vsep=" ")
     output = gs.read_command("g.region", flags="ug", env=env, **params)