From 1d358b275ecdd7164b958982b07b8b6e40afcf5b Mon Sep 17 00:00:00 2001 From: dyrpsf Date: Wed, 22 Jul 2026 09:02:39 +0530 Subject: [PATCH 1/6] feat: add container formatting options to GeoJSON outputs --- src/h3/_h3shape.py | 56 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/src/h3/_h3shape.py b/src/h3/_h3shape.py index 5c3629512..3586d2952 100644 --- a/src/h3/_h3shape.py +++ b/src/h3/_h3shape.py @@ -184,13 +184,6 @@ def __geo_interface__(self): LL1: list of LL0s LL2: list of LL1s (i.e., a polygon with holes) LL3: list of LL2s (i.e., several polygons with holes) - - -## TODO - -- Allow user to specify "container" in `cells_to_geojson`. - - That is, they may want a MultiPolygon even if the output fits in a Polygon - - 'auto', Polygon, MultiPolygon, FeatureCollection, GeometryCollection, ... """ @@ -325,14 +318,59 @@ def geo_to_h3shape(geo): return shape -def h3shape_to_geo(h3shape): +def h3shape_to_geo(h3shape, container='auto'): """ Translate from an ``H3Shape`` to a ``__geo_interface__`` dict. ``h3shape`` should be either ``LatLngPoly`` or ``LatLngMultiPoly`` + + Parameters + ---------- + container : str, optional + Specify the desired GeoJSON output container. + Options: 'auto', 'Polygon', 'MultiPolygon', 'Feature', + 'FeatureCollection', or 'GeometryCollection'. + Default is 'auto' (returns the simplest valid geometry). Returns ------- dict """ - return h3shape.__geo_interface__ + base_geo = h3shape.__geo_interface__ + + if container == 'auto' or container == base_geo['type']: + return base_geo + + # Upgrade a Polygon to a MultiPolygon if explicitly requested + if container == 'MultiPolygon' and base_geo['type'] == 'Polygon': + return { + 'type': 'MultiPolygon', + 'coordinates': (base_geo['coordinates'],) + } + + if container == 'Feature': + return { + 'type': 'Feature', + 'geometry': base_geo, + 'properties': {} + } + + if container == 'FeatureCollection': + return { + 'type': 'FeatureCollection', + 'features': [{ + 'type': 'Feature', + 'geometry': base_geo, + 'properties': {} + }] + } + + if container == 'GeometryCollection': + return { + 'type': 'GeometryCollection', + 'geometries': [base_geo] + } + + # If a downgrade is requested (e.g., MultiPolygon to Polygon) which + # risks data loss, or an unknown container is passed, return safely. + return base_geo From b1f5f9abc8dc942c49cf4db501e4adbdde52ed38 Mon Sep 17 00:00:00 2001 From: dyrpsf Date: Wed, 22 Jul 2026 16:02:28 +0530 Subject: [PATCH 2/6] test: add unit tests for GeoJSON container options; fix linting --- src/h3/_h3shape.py | 14 ++++++------- tests/test_geojson_containers.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 tests/test_geojson_containers.py diff --git a/src/h3/_h3shape.py b/src/h3/_h3shape.py index 3586d2952..93059e78f 100644 --- a/src/h3/_h3shape.py +++ b/src/h3/_h3shape.py @@ -337,24 +337,24 @@ def h3shape_to_geo(h3shape, container='auto'): dict """ base_geo = h3shape.__geo_interface__ - + if container == 'auto' or container == base_geo['type']: return base_geo - + # Upgrade a Polygon to a MultiPolygon if explicitly requested if container == 'MultiPolygon' and base_geo['type'] == 'Polygon': return { 'type': 'MultiPolygon', 'coordinates': (base_geo['coordinates'],) } - + if container == 'Feature': return { 'type': 'Feature', 'geometry': base_geo, 'properties': {} } - + if container == 'FeatureCollection': return { 'type': 'FeatureCollection', @@ -364,13 +364,13 @@ def h3shape_to_geo(h3shape, container='auto'): 'properties': {} }] } - + if container == 'GeometryCollection': return { 'type': 'GeometryCollection', 'geometries': [base_geo] } - - # If a downgrade is requested (e.g., MultiPolygon to Polygon) which + + # If a downgrade is requested (e.g., MultiPolygon to Polygon) which # risks data loss, or an unknown container is passed, return safely. return base_geo diff --git a/tests/test_geojson_containers.py b/tests/test_geojson_containers.py new file mode 100644 index 000000000..2ecf95222 --- /dev/null +++ b/tests/test_geojson_containers.py @@ -0,0 +1,35 @@ +import pytest +from h3._h3shape import LatLngPoly, h3shape_to_geo + +def test_h3shape_to_geo_containers(): + # Create a basic dummy polygon for testing + poly = LatLngPoly( + [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)] + ) + + # Test 'auto' (Default behavior should return a Polygon) + auto_geo = h3shape_to_geo(poly, container='auto') + assert auto_geo['type'] == 'Polygon' + + # Test MultiPolygon wrapper + mp_geo = h3shape_to_geo(poly, container='MultiPolygon') + assert mp_geo['type'] == 'MultiPolygon' + assert len(mp_geo['coordinates']) == 1 + + # Test Feature wrapper + feat_geo = h3shape_to_geo(poly, container='Feature') + assert feat_geo['type'] == 'Feature' + assert feat_geo['geometry']['type'] == 'Polygon' + + # Test FeatureCollection wrapper + fc_geo = h3shape_to_geo(poly, container='FeatureCollection') + assert fc_geo['type'] == 'FeatureCollection' + assert len(fc_geo['features']) == 1 + assert fc_geo['features'][0]['type'] == 'Feature' + assert fc_geo['features'][0]['geometry']['type'] == 'Polygon' + + # Test GeometryCollection wrapper + gc_geo = h3shape_to_geo(poly, container='GeometryCollection') + assert gc_geo['type'] == 'GeometryCollection' + assert len(gc_geo['geometries']) == 1 + assert gc_geo['geometries'][0]['type'] == 'Polygon' \ No newline at end of file From e02b8a6e95ab66ee31d8a6d77a79e1924d8ed938 Mon Sep 17 00:00:00 2001 From: dyrpsf Date: Wed, 22 Jul 2026 20:40:46 +0530 Subject: [PATCH 3/6] fix: raise ValueError for invalid or insufficient GeoJSON containers --- src/h3/_h3shape.py | 8 +++++--- tests/test_geojson_containers.py | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/h3/_h3shape.py b/src/h3/_h3shape.py index 93059e78f..477a5ec55 100644 --- a/src/h3/_h3shape.py +++ b/src/h3/_h3shape.py @@ -371,6 +371,8 @@ def h3shape_to_geo(h3shape, container='auto'): 'geometries': [base_geo] } - # If a downgrade is requested (e.g., MultiPolygon to Polygon) which - # risks data loss, or an unknown container is passed, return safely. - return base_geo + # If we reach here, the requested container is either unknown or + # insufficient for the data (e.g., requesting a Polygon for MultiPolygon data). + raise ValueError( + f"Requested container '{container}' is invalid or insufficient for data of type '{base_geo['type']}'." + ) diff --git a/tests/test_geojson_containers.py b/tests/test_geojson_containers.py index 2ecf95222..bfc3a4174 100644 --- a/tests/test_geojson_containers.py +++ b/tests/test_geojson_containers.py @@ -1,5 +1,5 @@ import pytest -from h3._h3shape import LatLngPoly, h3shape_to_geo +from h3._h3shape import LatLngPoly, h3shape_to_geo, LatLngMultiPoly def test_h3shape_to_geo_containers(): # Create a basic dummy polygon for testing @@ -32,4 +32,17 @@ def test_h3shape_to_geo_containers(): gc_geo = h3shape_to_geo(poly, container='GeometryCollection') assert gc_geo['type'] == 'GeometryCollection' assert len(gc_geo['geometries']) == 1 - assert gc_geo['geometries'][0]['type'] == 'Polygon' \ No newline at end of file + assert gc_geo['geometries'][0]['type'] == 'Polygon' + +def test_h3shape_to_geo_invalid_containers(): + # 1. Test an unknown container string + poly = LatLngPoly([(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)]) + with pytest.raises(ValueError, match="invalid or insufficient"): + h3shape_to_geo(poly, container='InvalidString') + + # 2. Test an insufficient container (MultiPolygon data into a Polygon container) + mpoly = LatLngMultiPoly([ + [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)] + ]) + with pytest.raises(ValueError, match="invalid or insufficient"): + h3shape_to_geo(mpoly, container='Polygon') \ No newline at end of file From a2add15580cebc424d66df6081324b103401dc90 Mon Sep 17 00:00:00 2001 From: dyrpsf Date: Wed, 22 Jul 2026 22:37:23 +0530 Subject: [PATCH 4/6] test: verify MultiPolygon to MultiPolygon matching; fix line length --- src/h3/_h3shape.py | 12 +++++++----- tests/test_geojson_containers.py | 28 +++++++++++++++++++++------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/h3/_h3shape.py b/src/h3/_h3shape.py index 477a5ec55..7aa7f628f 100644 --- a/src/h3/_h3shape.py +++ b/src/h3/_h3shape.py @@ -323,12 +323,12 @@ def h3shape_to_geo(h3shape, container='auto'): Translate from an ``H3Shape`` to a ``__geo_interface__`` dict. ``h3shape`` should be either ``LatLngPoly`` or ``LatLngMultiPoly`` - + Parameters ---------- container : str, optional - Specify the desired GeoJSON output container. - Options: 'auto', 'Polygon', 'MultiPolygon', 'Feature', + Specify the desired GeoJSON output container. + Options: 'auto', 'Polygon', 'MultiPolygon', 'Feature', 'FeatureCollection', or 'GeometryCollection'. Default is 'auto' (returns the simplest valid geometry). @@ -338,6 +338,7 @@ def h3shape_to_geo(h3shape, container='auto'): """ base_geo = h3shape.__geo_interface__ + # Return immediately if no wrapping is needed if container == 'auto' or container == base_geo['type']: return base_geo @@ -371,8 +372,9 @@ def h3shape_to_geo(h3shape, container='auto'): 'geometries': [base_geo] } - # If we reach here, the requested container is either unknown or + # If we reach here, the requested container is either unknown or # insufficient for the data (e.g., requesting a Polygon for MultiPolygon data). raise ValueError( - f"Requested container '{container}' is invalid or insufficient for data of type '{base_geo['type']}'." + f"Requested container '{container}' is invalid or insufficient " + f"for data of type '{base_geo['type']}'." ) diff --git a/tests/test_geojson_containers.py b/tests/test_geojson_containers.py index bfc3a4174..35629d554 100644 --- a/tests/test_geojson_containers.py +++ b/tests/test_geojson_containers.py @@ -1,26 +1,27 @@ import pytest from h3._h3shape import LatLngPoly, h3shape_to_geo, LatLngMultiPoly + def test_h3shape_to_geo_containers(): # Create a basic dummy polygon for testing poly = LatLngPoly( [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)] ) - + # Test 'auto' (Default behavior should return a Polygon) auto_geo = h3shape_to_geo(poly, container='auto') assert auto_geo['type'] == 'Polygon' - + # Test MultiPolygon wrapper mp_geo = h3shape_to_geo(poly, container='MultiPolygon') assert mp_geo['type'] == 'MultiPolygon' assert len(mp_geo['coordinates']) == 1 - + # Test Feature wrapper feat_geo = h3shape_to_geo(poly, container='Feature') assert feat_geo['type'] == 'Feature' assert feat_geo['geometry']['type'] == 'Polygon' - + # Test FeatureCollection wrapper fc_geo = h3shape_to_geo(poly, container='FeatureCollection') assert fc_geo['type'] == 'FeatureCollection' @@ -34,15 +35,28 @@ def test_h3shape_to_geo_containers(): assert len(gc_geo['geometries']) == 1 assert gc_geo['geometries'][0]['type'] == 'Polygon' + def test_h3shape_to_geo_invalid_containers(): # 1. Test an unknown container string poly = LatLngPoly([(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)]) - with pytest.raises(ValueError, match="invalid or insufficient"): + with pytest.raises(ValueError, match='invalid or insufficient'): h3shape_to_geo(poly, container='InvalidString') # 2. Test an insufficient container (MultiPolygon data into a Polygon container) mpoly = LatLngMultiPoly([ [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)] ]) - with pytest.raises(ValueError, match="invalid or insufficient"): - h3shape_to_geo(mpoly, container='Polygon') \ No newline at end of file + with pytest.raises(ValueError, match='invalid or insufficient'): + h3shape_to_geo(mpoly, container='Polygon') + + +def test_h3shape_to_geo_exact_match(): + # Test that requesting a MultiPolygon for MultiPolygon data succeeds + mpoly = LatLngMultiPoly([ + [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)] + ]) + + mp_geo = h3shape_to_geo(mpoly, container='MultiPolygon') + + assert mp_geo['type'] == 'MultiPolygon' + assert len(mp_geo['coordinates']) == 1 From 03678bb3ed49ef93a7e84cec1f5cb7d61443fed6 Mon Sep 17 00:00:00 2001 From: dyrpsf Date: Sat, 25 Jul 2026 23:16:31 +0530 Subject: [PATCH 5/6] test: fix LatLngMultiPoly initialization to resolve CI crash and restore coverage --- tests/test_geojson_containers.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_geojson_containers.py b/tests/test_geojson_containers.py index 35629d554..5421353ae 100644 --- a/tests/test_geojson_containers.py +++ b/tests/test_geojson_containers.py @@ -43,20 +43,20 @@ def test_h3shape_to_geo_invalid_containers(): h3shape_to_geo(poly, container='InvalidString') # 2. Test an insufficient container (MultiPolygon data into a Polygon container) - mpoly = LatLngMultiPoly([ - [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)] - ]) + mpoly = LatLngMultiPoly( + LatLngPoly([(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)]) + ) with pytest.raises(ValueError, match='invalid or insufficient'): h3shape_to_geo(mpoly, container='Polygon') def test_h3shape_to_geo_exact_match(): # Test that requesting a MultiPolygon for MultiPolygon data succeeds - mpoly = LatLngMultiPoly([ - [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)] - ]) + mpoly = LatLngMultiPoly( + LatLngPoly([(37.68, -122.54), (37.68, -122.34), (37.82, -122.34)]) + ) mp_geo = h3shape_to_geo(mpoly, container='MultiPolygon') assert mp_geo['type'] == 'MultiPolygon' - assert len(mp_geo['coordinates']) == 1 + assert len(mp_geo['coordinates']) == 1 \ No newline at end of file From 55530fa42b34dd7aacc913a65d5ada6d54895fea Mon Sep 17 00:00:00 2001 From: dyrpsf Date: Sun, 26 Jul 2026 00:43:15 +0530 Subject: [PATCH 6/6] test: move tests to test_lib for coverage; fix EOF newline --- tests/{ => test_lib}/test_geojson_containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{ => test_lib}/test_geojson_containers.py (98%) diff --git a/tests/test_geojson_containers.py b/tests/test_lib/test_geojson_containers.py similarity index 98% rename from tests/test_geojson_containers.py rename to tests/test_lib/test_geojson_containers.py index 5421353ae..a890c2761 100644 --- a/tests/test_geojson_containers.py +++ b/tests/test_lib/test_geojson_containers.py @@ -59,4 +59,4 @@ def test_h3shape_to_geo_exact_match(): mp_geo = h3shape_to_geo(mpoly, container='MultiPolygon') assert mp_geo['type'] == 'MultiPolygon' - assert len(mp_geo['coordinates']) == 1 \ No newline at end of file + assert len(mp_geo['coordinates']) == 1